@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.
Files changed (34) 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 +244 -0
  4. package/skills/markcut/docs/markdown-descriptive.md +2 -0
  5. package/src/descriptive/compiler.ts +41 -0
  6. package/src/descriptive/dsl.ts +42 -5
  7. package/src/descriptive/markdown.ts +5 -0
  8. package/src/descriptive/resolve.test.ts +5 -5
  9. package/src/descriptive/resolve.ts +51 -12
  10. package/src/player/bundle/player.js +448 -99
  11. package/src/player/pipeline.mjs +64 -13
  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 +56 -1
  17. package/src/spots/cli.mjs +266 -0
  18. package/src/types/Map.tsx +501 -127
  19. package/src/utils/tween.ts +49 -1
  20. package/tests/dsl.test.ts +43 -0
  21. package/tests/fixtures/map-dynamic.json +52 -0
  22. package/tests/fixtures/md/animate-diagrams.md +9 -7
  23. package/tests/fixtures/md/map-all-views.md +28 -0
  24. package/tests/md-descriptive.test.ts +58 -0
  25. package/tests/render.test.ts +1 -0
  26. package/tests/schema.test.ts +58 -1
  27. package/tests/validate-assets.test.ts +106 -0
  28. package/B] +0 -2
  29. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  30. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  31. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  32. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
  33. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  34. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
@@ -343,6 +343,7 @@ function compileLeaf(node2, ctx, parentKind) {
343
343
  const stream = {
344
344
  ...base,
345
345
  type: "map",
346
+ view: node2.view ?? "route",
346
347
  waypoints: node2.waypoints,
347
348
  routeColor: node2.routeColor ?? "#4285F4",
348
349
  routeWeight: node2.routeWeight ?? 4,
@@ -353,6 +354,9 @@ function compileLeaf(node2, ctx, parentKind) {
353
354
  region: node2.region,
354
355
  travelMode: node2.travelMode ?? "DRIVING",
355
356
  routeMarker: node2.routeMarker ?? "\u{1F697}",
357
+ camera: node2.camera,
358
+ cinematic: node2.cinematic,
359
+ streetView: node2.streetView,
356
360
  googleMapsApiKey: ctx.googleMapsApiKey
357
361
  };
358
362
  return { stream, duration: end ?? 0 };
@@ -9953,18 +9957,41 @@ function parseWaypoints(raw) {
9953
9957
  const lat = Number(bits[0] ?? 0);
9954
9958
  const lng = Number(bits[1] ?? 0);
9955
9959
  const labelRaw = bits[2];
9956
- const label = labelRaw ? unquote(labelRaw) : void 0;
9957
- return { lat, lng, label };
9960
+ const labelRawUq = labelRaw ? unquote(labelRaw) : void 0;
9961
+ const label = labelRawUq ? labelRawUq : void 0;
9962
+ const mediaRaw = bits[3];
9963
+ const media = mediaRaw ? unquote(mediaRaw) : void 0;
9964
+ return { lat, lng, label, media };
9958
9965
  });
9959
9966
  }
9967
+ function rewriteTweenExprs(s) {
9968
+ return s.replace(
9969
+ /tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
9970
+ (_match, fromRaw, toRaw, easingRaw) => {
9971
+ const scalar = (v) => {
9972
+ const t = v.trim();
9973
+ if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
9974
+ if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
9975
+ if (t === "true" || t === "false" || t === "null") return t;
9976
+ return JSON.stringify(t);
9977
+ };
9978
+ const items = [scalar(fromRaw), scalar(toRaw)];
9979
+ if (easingRaw !== void 0 && easingRaw.trim()) {
9980
+ items.push(scalar(easingRaw));
9981
+ }
9982
+ return `{"__tween":[${items.join(",")}]}`;
9983
+ }
9984
+ );
9985
+ }
9960
9986
  function parseProps(raw) {
9961
9987
  const s = raw.trim();
9962
9988
  if (!s.startsWith("{") && !s.startsWith("[")) return {};
9963
9989
  if (!s.endsWith("}") && !s.endsWith("]")) return {};
9990
+ const withTweens = rewriteTweenExprs(s);
9964
9991
  try {
9965
- return JSON.parse(s);
9992
+ return JSON.parse(withTweens);
9966
9993
  } catch {
9967
- let normalized = s.replace(
9994
+ let normalized = withTweens.replace(
9968
9995
  /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
9969
9996
  '$1"$2":'
9970
9997
  );
@@ -9980,7 +10007,7 @@ function parseProps(raw) {
9980
10007
  return JSON.parse(normalized);
9981
10008
  } catch {
9982
10009
  try {
9983
- const result = (0, eval)("(" + s + ")");
10010
+ const result = (0, eval)("(" + withTweens + ")");
9984
10011
  return typeof result === "object" && result !== null ? result : {};
9985
10012
  } catch {
9986
10013
  return {};
@@ -10233,6 +10260,10 @@ function preserveVariantAttrs(node2, attrs) {
10233
10260
  "mapType",
10234
10261
  "data",
10235
10262
  "prompt",
10263
+ "view",
10264
+ "camera",
10265
+ "cinematic",
10266
+ "streetView",
10236
10267
  "name",
10237
10268
  "title",
10238
10269
  "transition",
@@ -10455,6 +10486,7 @@ function parseNodeLine(content3, lineNum) {
10455
10486
  waypoints: attrs.waypoints ?? [],
10456
10487
  duration: attrs.duration,
10457
10488
  start: attrs.start,
10489
+ view: attrs.view,
10458
10490
  routeMarker: attrs.routeMarker,
10459
10491
  travelMode: attrs.travelMode,
10460
10492
  routeColor: attrs.routeColor,
@@ -10462,6 +10494,9 @@ function parseNodeLine(content3, lineNum) {
10462
10494
  zoom: attrs.zoom,
10463
10495
  center: attrs.center,
10464
10496
  mapType: attrs.mapType,
10497
+ camera: attrs.camera,
10498
+ cinematic: attrs.cinematic,
10499
+ streetView: attrs.streetView,
10465
10500
  language: attrs.language ?? attrs.lang,
10466
10501
  region: attrs.region,
10467
10502
  instruction: attrs.instruction,
@@ -10764,7 +10799,22 @@ function probeDuration(src, baseDir) {
10764
10799
  }
10765
10800
  function resolveSrc(src, baseDir) {
10766
10801
  if (/^(https?:|file:|\/)/.test(src)) return src;
10767
- return resolvePath(baseDir ?? process.cwd(), src);
10802
+ return resolvePath(baseDir, src);
10803
+ }
10804
+ function relativizeAssetsUnder(node2, baseDir) {
10805
+ if (!node2 || typeof node2 !== "object") return node2;
10806
+ if (Array.isArray(node2)) return node2.map((v) => relativizeAssetsUnder(v, baseDir));
10807
+ const out = {};
10808
+ for (const [k, v] of Object.entries(node2)) {
10809
+ if (typeof v === "string" && v.startsWith(baseDir + "/")) {
10810
+ out[k] = v.slice(baseDir.length + 1);
10811
+ } else if (v && typeof v === "object") {
10812
+ out[k] = relativizeAssetsUnder(v, baseDir);
10813
+ } else {
10814
+ out[k] = v;
10815
+ }
10816
+ }
10817
+ return out;
10768
10818
  }
10769
10819
  var COMMON_RESOLUTIONS = [
10770
10820
  { width: 1920, height: 1080 },
@@ -10809,7 +10859,7 @@ function resolveMediaSrc(src, targetWidth, targetHeight, baseDir) {
10809
10859
  console.warn(` \u26A0 No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
10810
10860
  return exactAbs;
10811
10861
  }
10812
- async function resolveMediaSrcs(root, options = {}) {
10862
+ async function resolveMediaSrcs(root, options) {
10813
10863
  const clone = JSON.parse(JSON.stringify(root));
10814
10864
  const baseDir = options.baseDir;
10815
10865
  const targetWidth = clone.width ?? 1080;
@@ -10830,7 +10880,7 @@ async function resolveMediaSrcs(root, options = {}) {
10830
10880
  });
10831
10881
  return clone;
10832
10882
  }
10833
- async function resolveMediaDurations(root, options = {}) {
10883
+ async function resolveMediaDurations(root, options) {
10834
10884
  const clone = JSON.parse(JSON.stringify(root));
10835
10885
  const baseDir = options.baseDir;
10836
10886
  walkDown(clone, (node2) => {
@@ -11242,9 +11292,9 @@ function applyStoryboardOverrides(root, options) {
11242
11292
  }
11243
11293
  return clone;
11244
11294
  }
11245
- async function resolveIncludes(root, options = {}) {
11295
+ async function resolveIncludes(root, options) {
11246
11296
  const clone = JSON.parse(JSON.stringify(root));
11247
- const baseDir = options.baseDir ?? process.cwd();
11297
+ const baseDir = options.baseDir;
11248
11298
  const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
11249
11299
  mkdirSync2(outputDir, { recursive: true });
11250
11300
  function extractImportEntriesFromRaw(raw) {
@@ -11323,7 +11373,7 @@ async function resolveIncludes(root, options = {}) {
11323
11373
  }
11324
11374
  return clone;
11325
11375
  }
11326
- async function resolveAll2(root, options = {}) {
11376
+ async function resolveAll2(root, options) {
11327
11377
  let result = root;
11328
11378
  if (result.seed == null && options.seed == null && options.sourcePath) {
11329
11379
  const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
@@ -11391,6 +11441,7 @@ async function resolveAll2(root, options = {}) {
11391
11441
  mergedOutputDir: options.subtitleOutputDir
11392
11442
  });
11393
11443
  }
11444
+ result = relativizeAssetsUnder(result, options.baseDir);
11394
11445
  return result;
11395
11446
  }
11396
11447
 
@@ -11408,7 +11459,7 @@ function isDescriptiveRoot(data) {
11408
11459
  )) return true;
11409
11460
  return false;
11410
11461
  }
11411
- async function resolveAndCompile(data, options = {}) {
11462
+ async function resolveAndCompile(data, options) {
11412
11463
  const resolved = await resolveAll2(data, {
11413
11464
  sourcePath: options.sourcePath,
11414
11465
  baseDir: options.baseDir,
@@ -11427,7 +11478,7 @@ async function resolveAndCompile(data, options = {}) {
11427
11478
  });
11428
11479
  return compiled;
11429
11480
  }
11430
- async function resolveAndCompileMarkdown(markdown, options = {}) {
11481
+ async function resolveAndCompileMarkdown(markdown, options) {
11431
11482
  const descriptive = parseMarkdownDescriptive(markdown);
11432
11483
  return resolveAndCompile(descriptive, options);
11433
11484
  }
@@ -21,8 +21,9 @@ import type { DescriptiveRoot } from "../descriptive/compiler";
21
21
  import type { Root } from "../schema/index";
22
22
 
23
23
  export interface ResolveAndCompileOptions {
24
- /** Base directory for resolving relative media src paths */
25
- baseDir?: string;
24
+ /** Base directory (the source file's folder) for resolving/emitting asset paths.
25
+ * Always set by the CLI/server; no fallback. */
26
+ baseDir: string;
26
27
  /** Output directory for generated TTS audio / STT VTT files */
27
28
  scriptOutputDir?: string;
28
29
  /** Output directory for generated TTI/TTV media files */
@@ -88,7 +89,7 @@ export function isDescriptiveRoot(data: any): boolean {
88
89
  */
89
90
  export async function resolveAndCompile(
90
91
  data: DescriptiveRoot,
91
- options: ResolveAndCompileOptions = {},
92
+ options: ResolveAndCompileOptions,
92
93
  ): Promise<Root> {
93
94
  // 1. Async resolve: durations, TTS, STT, includes
94
95
  const resolved = await resolveAll(data, {
@@ -121,7 +122,7 @@ export async function resolveAndCompile(
121
122
  */
122
123
  export async function resolveAndCompileMarkdown(
123
124
  markdown: string,
124
- options: ResolveAndCompileOptions = {},
125
+ options: ResolveAndCompileOptions,
125
126
  ): Promise<Root> {
126
127
  const descriptive = parseMarkdownDescriptive(markdown);
127
128
  return resolveAndCompile(descriptive, options);
@@ -326,59 +326,39 @@ function makePathsRelative(obj) {
326
326
  }
327
327
 
328
328
  /**
329
- * Convert relative (to MARKCUT_BASE) paths in the compiled root to server-relative URLs
330
- * for the browser. This is the inverse of makePathsRelative — it prepends "/" to paths
331
- * so the browser can fetch them from the server.
329
+ * Convert asset paths in the compiled root to server-relative URLs for the
330
+ * browser. The compiled JSON now carries md-folder-relative paths (e.g.
331
+ * ".markcut/generated/tts/x.mp3" or "assets/photo.jpg") because the resolve
332
+ * step emits them relative to the source .md folder — the server document
333
+ * root. This prepends "/" so the browser can fetch them (resolveAsset() then
334
+ * maps the URL back onto the md folder).
332
335
  *
333
- * Handles:
334
- * - compiled.imports (component bundle URL)
335
- * - compiled.subtitle.src (VTT file)
336
- * - Any node.src (media/tts/images) that is a relative path under MARKCUT_BASE
336
+ * Also handles legacy absolute paths under MARKCUT_BASE (older cached trees).
337
337
  */
338
338
  function resolveAssetPaths(root) {
339
339
  const out = JSON.parse(JSON.stringify(root));
340
340
 
341
+ function toServerUrl(p) {
342
+ if (typeof p !== "string") return p;
343
+ if (/^(https?:|data:|blob:)/.test(p)) return p;
344
+ if (p.startsWith("/")) return p; // already a server URL
345
+ // Legacy: absolute path under the .markcut base (older cached trees)
346
+ if (p.startsWith(MARKCUT_BASE)) return p.slice(MARKCUT_BASE.length);
347
+ // md-folder-relative path (new scheme): ".markcut/...", "assets/..."
348
+ const rel = p.startsWith("./") ? p.slice(2) : p;
349
+ return "/" + rel;
350
+ }
351
+
341
352
  function walkNode(node) {
342
353
  if (!node || typeof node !== "object") return;
343
354
  if (Array.isArray(node)) { node.forEach(walkNode); return; }
344
355
 
345
- // Convert src fields that are relative paths
346
- if (typeof node.src === "string" && !node.src.startsWith("http://") && !node.src.startsWith("https://") && !node.src.startsWith("data:")) {
347
- // Already a relative path under MARKCUT_BASE (shouldn't be absolute)
348
- if (!node.src.startsWith("/") && !node.src.startsWith(".")) {
349
- // Plain relative path — prefix with / (server serves from project root)
350
- node.src = "/" + node.src;
351
- } else if (node.src.startsWith("./")) {
352
- // Dot-prefixed relative path — strip ./ and prefix with /
353
- node.src = "/" + node.src.slice(2);
354
- }
355
- // Convert absolute paths under MARKCUT_BASE
356
- if (typeof node.src === "string" && node.src.startsWith(MARKCUT_BASE)) {
357
- node.src = "/" + node.src.replace(MARKCUT_BASE + "/", "");
358
- }
359
- }
360
-
361
- // Convert subtitle src
356
+ if (typeof node.src === "string") node.src = toServerUrl(node.src);
362
357
  if (node.subtitle && typeof node.subtitle.src === "string") {
363
- if (node.subtitle.src.startsWith(MARKCUT_BASE)) {
364
- node.subtitle.src = "/" + node.subtitle.src.replace(MARKCUT_BASE + "/", "");
365
- } else if (!node.subtitle.src.startsWith("/") && !node.subtitle.src.startsWith("http")) {
366
- node.subtitle.src = "/" + node.subtitle.src;
367
- }
368
- }
369
-
370
- // Convert imports (component bundle URL)
371
- if (typeof node.imports === "string") {
372
- if (node.imports.startsWith(MARKCUT_BASE)) {
373
- node.imports = "/" + node.imports.replace(MARKCUT_BASE + "/", "");
374
- } else if (!node.imports.startsWith("/") && !node.imports.startsWith("http")) {
375
- node.imports = "/" + node.imports;
376
- }
377
- }
378
-
379
- if (Array.isArray(node.children)) {
380
- node.children.forEach(walkNode);
358
+ node.subtitle.src = toServerUrl(node.subtitle.src);
381
359
  }
360
+ if (typeof node.imports === "string") node.imports = toServerUrl(node.imports);
361
+ if (Array.isArray(node.children)) node.children.forEach(walkNode);
382
362
  }
383
363
 
384
364
  walkNode(out);
@@ -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
+ }
@@ -212,7 +212,7 @@ export const scene = base.extend({
212
212
  export type Scene = z.infer<typeof scene>;
213
213
 
214
214
  // ---------------------------------------------------------------------------
215
- // Map — animated route visualization
215
+ // Map — animated route visualization with dynamic camera views
216
216
  // ---------------------------------------------------------------------------
217
217
  export const mapWaypoint = z.object({
218
218
  lat: z.number(),
@@ -221,8 +221,60 @@ export const mapWaypoint = z.object({
221
221
  media: z.string().optional().describe("image/video src for waypoint marker"),
222
222
  });
223
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
+
224
275
  export const mapStream = base.extend({
225
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"),
226
278
  waypoints: z.array(mapWaypoint).default(() => []),
227
279
  routeColor: z.string().default("#4285F4"),
228
280
  routeWeight: z.number().default(4),
@@ -233,6 +285,9 @@ export const mapStream = base.extend({
233
285
  region: z.string().optional().describe("Google Maps region code, e.g. CN"),
234
286
  travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
235
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"),
236
291
  googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
237
292
  });
238
293
  export type MapStream = z.infer<typeof mapStream>;