@lalalic/markcut 2.9.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/components.md +45 -2
  4. package/skills/markcut/docs/map-dynamic-camera.md +244 -0
  5. package/skills/markcut/docs/markdown-descriptive.md +7 -2
  6. package/src/components/Markdown.tsx +138 -24
  7. package/src/components/Mermaid.tsx +223 -22
  8. package/src/context/EventContext.tsx +3 -0
  9. package/src/descriptive/compiler.ts +105 -29
  10. package/src/descriptive/dsl.ts +42 -5
  11. package/src/descriptive/markdown.ts +23 -0
  12. package/src/descriptive/resolve.test.ts +5 -5
  13. package/src/descriptive/resolve.ts +51 -12
  14. package/src/player/bundle/player.js +751 -143
  15. package/src/player/pipeline.mjs +130 -32
  16. package/src/player/pipeline.ts +5 -4
  17. package/src/player/server.mjs +22 -42
  18. package/src/render/cli.mjs +54 -3
  19. package/src/render/validate-assets.mjs +140 -0
  20. package/src/schema/index.ts +58 -2
  21. package/src/spots/cli.mjs +266 -0
  22. package/src/types/Component.tsx +27 -1
  23. package/src/types/Effect.tsx +13 -6
  24. package/src/types/Folder.tsx +1 -1
  25. package/src/types/Map.tsx +501 -127
  26. package/src/utils/index.ts +14 -2
  27. package/src/utils/tween.ts +49 -1
  28. package/tests/dsl.test.ts +43 -0
  29. package/tests/fixtures/map-dynamic.json +52 -0
  30. package/tests/fixtures/md/animate-diagrams.md +42 -0
  31. package/tests/fixtures/md/electricity-grow.md +130 -0
  32. package/tests/fixtures/md/map-all-views.md +28 -0
  33. package/tests/md-descriptive.test.ts +58 -0
  34. package/tests/render.test.ts +1 -0
  35. package/tests/schema.test.ts +58 -1
  36. package/tests/validate-assets.test.ts +106 -0
@@ -33,7 +33,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
 
34
34
  // src/utils/index.ts
35
35
  function uid() {
36
- return Math.random().toString(36).slice(2, 10);
36
+ const raw = Math.random().toString(36).slice(2, 10);
37
+ const first = raw[0];
38
+ if (/^[0-9]/.test(first)) {
39
+ const letter = String.fromCharCode(97 + Math.floor(Math.random() * 26));
40
+ return letter + raw;
41
+ }
42
+ return raw;
37
43
  }
38
44
  function walkDown(node2, visit, parent = null, depth = 0) {
39
45
  const keep = visit(node2, parent, depth);
@@ -207,7 +213,8 @@ function wrapWithEffects(node2, result, parentKind) {
207
213
  const absStart = innerStream.start ?? 0;
208
214
  const absEnd = innerStream.end ?? result.duration;
209
215
  const duration = absEnd - absStart;
210
- const resetStream = {
216
+ const isBgNoEnd = innerStream.isBackground && innerStream.end == null;
217
+ const resetStream = isBgNoEnd ? { ...innerStream } : {
211
218
  ...innerStream,
212
219
  start: 0,
213
220
  end: duration,
@@ -224,28 +231,33 @@ function wrapWithEffects(node2, result, parentKind) {
224
231
  id: uid(),
225
232
  type: "effect",
226
233
  animation: spec.animation,
234
+ animationDurationSeconds: spec.duration,
227
235
  durationInSeconds: spec.duration,
228
236
  animationTimingFunction: spec.animationTimingFunction,
229
237
  animationIterationCount: spec.animationIterationCount ?? 1,
230
238
  customKeyframes: spec.customKeyframes,
231
239
  children: [currentStream],
232
- start: effStart,
233
- end: effEnd,
234
- visible: true,
240
+ // For background inner nodes: propagate start/end as-is so parent
241
+ // back-propagation fills the correct scene duration. The effect's
242
+ // durationInSeconds (animation spec) controls animation timing.
243
+ start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
244
+ end: isOutermost && isBgNoEnd ? void 0 : effEnd,
245
+ visible: innerStream.visible ?? true,
235
246
  ...pickOn(node2)
236
247
  };
237
248
  }
238
249
  return { stream: currentStream, duration: result.duration };
239
250
  }
240
251
  function compileLeaf(node2, ctx, parentKind) {
241
- const id = node2.id ?? uid();
252
+ const id = node2.id;
253
+ const hasExplicitId = node2.id != null;
242
254
  const hasOwnDuration = typeof node2.duration === "number" || typeof node2.endAt === "number";
243
255
  const isBgNoOwnTiming = node2.isBackground && !hasOwnDuration;
244
256
  const start = isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
245
257
  const duration = isBgNoOwnTiming ? void 0 : deriveLeafDuration(node2, ctx);
246
258
  const end = duration != null ? start + duration : void 0;
247
259
  const base = {
248
- id,
260
+ ...id ? { id } : {},
249
261
  style: node2.style,
250
262
  visible: node2.visible ?? true,
251
263
  isBackground: node2.isBackground,
@@ -305,8 +317,7 @@ function compileLeaf(node2, ctx, parentKind) {
305
317
  const bindings = {};
306
318
  for (const key of Object.keys(node2)) {
307
319
  if (!KNOWN_COMPONENT_KEYS.has(key)) {
308
- const val = node2[key];
309
- if (typeof val === "string") bindings[key] = val;
320
+ bindings[key] = node2[key];
310
321
  }
311
322
  }
312
323
  const stream = {
@@ -332,6 +343,7 @@ function compileLeaf(node2, ctx, parentKind) {
332
343
  const stream = {
333
344
  ...base,
334
345
  type: "map",
346
+ view: node2.view ?? "route",
335
347
  waypoints: node2.waypoints,
336
348
  routeColor: node2.routeColor ?? "#4285F4",
337
349
  routeWeight: node2.routeWeight ?? 4,
@@ -342,6 +354,9 @@ function compileLeaf(node2, ctx, parentKind) {
342
354
  region: node2.region,
343
355
  travelMode: node2.travelMode ?? "DRIVING",
344
356
  routeMarker: node2.routeMarker ?? "\u{1F697}",
357
+ camera: node2.camera,
358
+ cinematic: node2.cinematic,
359
+ streetView: node2.streetView,
345
360
  googleMapsApiKey: ctx.googleMapsApiKey
346
361
  };
347
362
  return { stream, duration: end ?? 0 };
@@ -407,13 +422,23 @@ function compileScene(node2, ctx, parentKind) {
407
422
  ];
408
423
  const sceneContentDuration = sceneKind === "parallel" ? aggregateDuration(compiledChildren, "parallel") : aggregateDuration(compiledChildren, sceneKind, resolved.time);
409
424
  const localDuration = Math.max(node2.duration ?? 0, sceneContentDuration);
410
- for (const c of compiledChildren) {
411
- if (c.stream.isBackground && c.stream.end == null) {
412
- c.stream.end = localDuration;
413
- c.stream.durationInSeconds = localDuration;
414
- if (c.stream.start == null) c.stream.start = 0;
425
+ function backpropagate(stream2, dur) {
426
+ if (stream2.end == null) {
427
+ stream2.end = dur;
428
+ if (stream2.durationInSeconds == null) {
429
+ stream2.durationInSeconds = dur;
430
+ }
431
+ if (stream2.start == null) stream2.start = 0;
432
+ }
433
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
434
+ for (const child of stream2.children) {
435
+ backpropagate(child, dur);
436
+ }
415
437
  }
416
438
  }
439
+ for (const c of compiledChildren) {
440
+ backpropagate(c.stream, localDuration);
441
+ }
417
442
  const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
418
443
  const end = start + localDuration;
419
444
  const stream = {
@@ -507,12 +532,22 @@ function compileContainer(node2, ctx, parentKind) {
507
532
  const resolved = node2.type === "transitionSeries" ? resolveTransition(node2.transition, node2.transitionTime) : { name: "fade", time: 0.5 };
508
533
  const children = compileChildren(node2.children, ctx, node2.type);
509
534
  const duration = aggregateDuration(children, node2.type, resolved.time);
510
- for (const c of children) {
511
- if (c.stream.isBackground && c.stream.end == null) {
512
- c.stream.end = duration;
513
- c.stream.durationInSeconds = duration;
514
- if (c.stream.start == null) c.stream.start = 0;
535
+ function backpropagate(stream2, dur) {
536
+ if (stream2.end == null) {
537
+ stream2.end = dur;
538
+ if (stream2.durationInSeconds == null) {
539
+ stream2.durationInSeconds = dur;
540
+ }
541
+ if (stream2.start == null) stream2.start = 0;
515
542
  }
543
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
544
+ for (const child of stream2.children) {
545
+ backpropagate(child, dur);
546
+ }
547
+ }
548
+ }
549
+ for (const c of children) {
550
+ backpropagate(c.stream, duration);
516
551
  }
517
552
  const stream = {
518
553
  id,
@@ -9922,18 +9957,41 @@ function parseWaypoints(raw) {
9922
9957
  const lat = Number(bits[0] ?? 0);
9923
9958
  const lng = Number(bits[1] ?? 0);
9924
9959
  const labelRaw = bits[2];
9925
- const label = labelRaw ? unquote(labelRaw) : void 0;
9926
- 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 };
9927
9965
  });
9928
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
+ }
9929
9986
  function parseProps(raw) {
9930
9987
  const s = raw.trim();
9931
9988
  if (!s.startsWith("{") && !s.startsWith("[")) return {};
9932
9989
  if (!s.endsWith("}") && !s.endsWith("]")) return {};
9990
+ const withTweens = rewriteTweenExprs(s);
9933
9991
  try {
9934
- return JSON.parse(s);
9992
+ return JSON.parse(withTweens);
9935
9993
  } catch {
9936
- let normalized = s.replace(
9994
+ let normalized = withTweens.replace(
9937
9995
  /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
9938
9996
  '$1"$2":'
9939
9997
  );
@@ -9949,7 +10007,7 @@ function parseProps(raw) {
9949
10007
  return JSON.parse(normalized);
9950
10008
  } catch {
9951
10009
  try {
9952
- const result = (0, eval)("(" + s + ")");
10010
+ const result = (0, eval)("(" + withTweens + ")");
9953
10011
  return typeof result === "object" && result !== null ? result : {};
9954
10012
  } catch {
9955
10013
  return {};
@@ -10122,6 +10180,7 @@ var TYPE_TOKENS = {
10122
10180
  video: "video",
10123
10181
  audio: "audio",
10124
10182
  component: "component",
10183
+ event: "event",
10125
10184
  rhythm: "rhythm",
10126
10185
  include: "include",
10127
10186
  map: "map",
@@ -10201,6 +10260,10 @@ function preserveVariantAttrs(node2, attrs) {
10201
10260
  "mapType",
10202
10261
  "data",
10203
10262
  "prompt",
10263
+ "view",
10264
+ "camera",
10265
+ "cinematic",
10266
+ "streetView",
10204
10267
  "name",
10205
10268
  "title",
10206
10269
  "transition",
@@ -10341,6 +10404,21 @@ function parseNodeLine(content3, lineNum) {
10341
10404
  preserveVariantAttrs(node2, attrs);
10342
10405
  return node2;
10343
10406
  }
10407
+ case "event": {
10408
+ const node2 = {
10409
+ type: "component",
10410
+ id: attrs.id,
10411
+ jsx: "",
10412
+ duration: attrs.duration,
10413
+ start: attrs.start,
10414
+ instruction: attrs.instruction,
10415
+ style: attrs.style,
10416
+ effects: attrs.effects,
10417
+ on: attrs.on
10418
+ };
10419
+ preserveVariantAttrs(node2, attrs);
10420
+ return node2;
10421
+ }
10344
10422
  case "rhythm": {
10345
10423
  const src = firstPositional ?? attrs.src;
10346
10424
  if (!src) throw new DslError("rhythm requires src", ctx);
@@ -10408,6 +10486,7 @@ function parseNodeLine(content3, lineNum) {
10408
10486
  waypoints: attrs.waypoints ?? [],
10409
10487
  duration: attrs.duration,
10410
10488
  start: attrs.start,
10489
+ view: attrs.view,
10411
10490
  routeMarker: attrs.routeMarker,
10412
10491
  travelMode: attrs.travelMode,
10413
10492
  routeColor: attrs.routeColor,
@@ -10415,6 +10494,9 @@ function parseNodeLine(content3, lineNum) {
10415
10494
  zoom: attrs.zoom,
10416
10495
  center: attrs.center,
10417
10496
  mapType: attrs.mapType,
10497
+ camera: attrs.camera,
10498
+ cinematic: attrs.cinematic,
10499
+ streetView: attrs.streetView,
10418
10500
  language: attrs.language ?? attrs.lang,
10419
10501
  region: attrs.region,
10420
10502
  instruction: attrs.instruction,
@@ -10717,7 +10799,22 @@ function probeDuration(src, baseDir) {
10717
10799
  }
10718
10800
  function resolveSrc(src, baseDir) {
10719
10801
  if (/^(https?:|file:|\/)/.test(src)) return src;
10720
- 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;
10721
10818
  }
10722
10819
  var COMMON_RESOLUTIONS = [
10723
10820
  { width: 1920, height: 1080 },
@@ -10762,7 +10859,7 @@ function resolveMediaSrc(src, targetWidth, targetHeight, baseDir) {
10762
10859
  console.warn(` \u26A0 No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
10763
10860
  return exactAbs;
10764
10861
  }
10765
- async function resolveMediaSrcs(root, options = {}) {
10862
+ async function resolveMediaSrcs(root, options) {
10766
10863
  const clone = JSON.parse(JSON.stringify(root));
10767
10864
  const baseDir = options.baseDir;
10768
10865
  const targetWidth = clone.width ?? 1080;
@@ -10783,7 +10880,7 @@ async function resolveMediaSrcs(root, options = {}) {
10783
10880
  });
10784
10881
  return clone;
10785
10882
  }
10786
- async function resolveMediaDurations(root, options = {}) {
10883
+ async function resolveMediaDurations(root, options) {
10787
10884
  const clone = JSON.parse(JSON.stringify(root));
10788
10885
  const baseDir = options.baseDir;
10789
10886
  walkDown(clone, (node2) => {
@@ -11195,9 +11292,9 @@ function applyStoryboardOverrides(root, options) {
11195
11292
  }
11196
11293
  return clone;
11197
11294
  }
11198
- async function resolveIncludes(root, options = {}) {
11295
+ async function resolveIncludes(root, options) {
11199
11296
  const clone = JSON.parse(JSON.stringify(root));
11200
- const baseDir = options.baseDir ?? process.cwd();
11297
+ const baseDir = options.baseDir;
11201
11298
  const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
11202
11299
  mkdirSync2(outputDir, { recursive: true });
11203
11300
  function extractImportEntriesFromRaw(raw) {
@@ -11276,7 +11373,7 @@ async function resolveIncludes(root, options = {}) {
11276
11373
  }
11277
11374
  return clone;
11278
11375
  }
11279
- async function resolveAll2(root, options = {}) {
11376
+ async function resolveAll2(root, options) {
11280
11377
  let result = root;
11281
11378
  if (result.seed == null && options.seed == null && options.sourcePath) {
11282
11379
  const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
@@ -11344,6 +11441,7 @@ async function resolveAll2(root, options = {}) {
11344
11441
  mergedOutputDir: options.subtitleOutputDir
11345
11442
  });
11346
11443
  }
11444
+ result = relativizeAssetsUnder(result, options.baseDir);
11347
11445
  return result;
11348
11446
  }
11349
11447
 
@@ -11361,7 +11459,7 @@ function isDescriptiveRoot(data) {
11361
11459
  )) return true;
11362
11460
  return false;
11363
11461
  }
11364
- async function resolveAndCompile(data, options = {}) {
11462
+ async function resolveAndCompile(data, options) {
11365
11463
  const resolved = await resolveAll2(data, {
11366
11464
  sourcePath: options.sourcePath,
11367
11465
  baseDir: options.baseDir,
@@ -11380,7 +11478,7 @@ async function resolveAndCompile(data, options = {}) {
11380
11478
  });
11381
11479
  return compiled;
11382
11480
  }
11383
- async function resolveAndCompileMarkdown(markdown, options = {}) {
11481
+ async function resolveAndCompileMarkdown(markdown, options) {
11384
11482
  const descriptive = parseMarkdownDescriptive(markdown);
11385
11483
  return resolveAndCompile(descriptive, options);
11386
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) {