@lalalic/markcut 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/map-dynamic-camera.md +328 -0
  4. package/skills/markcut/docs/markdown-descriptive.md +2 -0
  5. package/src/descriptive/compiler.ts +101 -1
  6. package/src/descriptive/dsl.ts +64 -7
  7. package/src/descriptive/markdown.ts +7 -1
  8. package/src/descriptive/resolve.test.ts +103 -5
  9. package/src/descriptive/resolve.ts +207 -24
  10. package/src/player/bundle/player.js +223296 -222155
  11. package/src/player/pipeline.mjs +314 -25
  12. package/src/player/pipeline.ts +5 -4
  13. package/src/player/server.mjs +22 -42
  14. package/src/render/cli.mjs +54 -3
  15. package/src/render/validate-assets.mjs +140 -0
  16. package/src/schema/index.ts +60 -1
  17. package/src/spots/cli.mjs +266 -0
  18. package/src/types/Effect.tsx +12 -1
  19. package/src/types/Map.tsx +1078 -130
  20. package/src/utils/directions.ts +101 -0
  21. package/src/utils/index.ts +11 -0
  22. package/src/utils/route-legs.ts +199 -0
  23. package/src/utils/tween.ts +49 -1
  24. package/tests/dsl.test.ts +78 -0
  25. package/tests/fixtures/map-dynamic.json +52 -0
  26. package/tests/fixtures/map-overlay.json +56 -0
  27. package/tests/fixtures/md/animate-diagrams.md +9 -7
  28. package/tests/fixtures/md/map-all-views.md +35 -0
  29. package/tests/fixtures/md/map-children.md +11 -0
  30. package/tests/fixtures/md/map-multimode.md +9 -0
  31. package/tests/fixtures/streetview-walk.json +36 -0
  32. package/tests/md-descriptive.test.ts +133 -0
  33. package/tests/render.test.ts +93 -0
  34. package/tests/route-legs.test.ts +178 -0
  35. package/tests/schema.test.ts +76 -1
  36. package/tests/validate-assets.test.ts +106 -0
  37. package/B] +0 -2
  38. package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
  39. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
  40. package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
  41. package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
  42. package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
  43. package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
@@ -243,6 +243,7 @@ function wrapWithEffects(node2, result, parentKind) {
243
243
  start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
244
244
  end: isOutermost && isBgNoEnd ? void 0 : effEnd,
245
245
  visible: innerStream.visible ?? true,
246
+ at: node2.at,
246
247
  ...pickOn(node2)
247
248
  };
248
249
  }
@@ -261,6 +262,7 @@ function compileLeaf(node2, ctx, parentKind) {
261
262
  style: node2.style,
262
263
  visible: node2.visible ?? true,
263
264
  isBackground: node2.isBackground,
265
+ at: node2.at,
264
266
  start: isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : start,
265
267
  end,
266
268
  startFrom: isBgNoOwnTiming ? void 0 : node2.type === "video" || node2.type === "audio" ? node2.startFrom : void 0,
@@ -343,6 +345,7 @@ function compileLeaf(node2, ctx, parentKind) {
343
345
  const stream = {
344
346
  ...base,
345
347
  type: "map",
348
+ view: node2.view ?? "route",
346
349
  waypoints: node2.waypoints,
347
350
  routeColor: node2.routeColor ?? "#4285F4",
348
351
  routeWeight: node2.routeWeight ?? 4,
@@ -353,6 +356,9 @@ function compileLeaf(node2, ctx, parentKind) {
353
356
  region: node2.region,
354
357
  travelMode: node2.travelMode ?? "DRIVING",
355
358
  routeMarker: node2.routeMarker ?? "\u{1F697}",
359
+ camera: node2.camera,
360
+ cinematic: node2.cinematic,
361
+ streetView: node2.streetView,
356
362
  googleMapsApiKey: ctx.googleMapsApiKey
357
363
  };
358
364
  return { stream, duration: end ?? 0 };
@@ -371,7 +377,7 @@ function compileChildren(children, ctx, parentKind) {
371
377
  } else if (isRhythm(child)) {
372
378
  result = compileRhythm(child, ctx, parentKind);
373
379
  } else if (isMap(child)) {
374
- result = compileLeaf(child, ctx, parentKind);
380
+ result = compileMap(child, ctx, parentKind);
375
381
  } else {
376
382
  result = compileLeaf(child, ctx, parentKind);
377
383
  }
@@ -522,6 +528,44 @@ function compileRhythm(node2, ctx, parentKind) {
522
528
  };
523
529
  return { stream, duration: end };
524
530
  }
531
+ function compileMap(node2, ctx, parentKind) {
532
+ const id = node2.id ?? uid();
533
+ const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
534
+ const ownDuration = deriveLeafDuration(node2, ctx);
535
+ const end = ownDuration != null ? start + ownDuration : void 0;
536
+ const children = node2.children ?? [];
537
+ const compiledChildren = children.length ? compileChildren(children, ctx, "parallel") : [];
538
+ const maxChildEnd = compiledChildren.reduce((max, c) => Math.max(max, c.duration), 0);
539
+ const mapDuration = Math.max(end ?? 0, maxChildEnd, ownDuration ?? 0);
540
+ const stream = {
541
+ id,
542
+ type: "map",
543
+ style: node2.style,
544
+ visible: node2.visible ?? true,
545
+ isBackground: node2.isBackground,
546
+ start,
547
+ end: mapDuration,
548
+ durationInSeconds: mapDuration,
549
+ view: node2.view ?? "route",
550
+ waypoints: node2.waypoints,
551
+ routeColor: node2.routeColor ?? "#4285F4",
552
+ routeWeight: node2.routeWeight ?? 4,
553
+ zoom: node2.zoom ?? 10,
554
+ center: node2.center,
555
+ mapType: node2.mapType ?? "roadmap",
556
+ language: node2.language,
557
+ region: node2.region,
558
+ travelMode: node2.travelMode ?? "DRIVING",
559
+ routeMarker: node2.routeMarker ?? "\u{1F697}",
560
+ camera: node2.camera,
561
+ cinematic: node2.cinematic,
562
+ streetView: node2.streetView,
563
+ googleMapsApiKey: ctx.googleMapsApiKey,
564
+ children: compiledChildren.map((c) => c.stream),
565
+ ...pickOn(node2)
566
+ };
567
+ return { stream, duration: mapDuration };
568
+ }
525
569
  function compileContainer(node2, ctx, parentKind) {
526
570
  const id = node2.id ?? uid();
527
571
  ensureUniqueIds(node2.children, id);
@@ -1034,6 +1078,114 @@ var require_format = __commonJS({
1034
1078
  }
1035
1079
  });
1036
1080
 
1081
+ // src/utils/route-legs.ts
1082
+ function haversineKm(a, b) {
1083
+ const dLat = toRad(b.lat - a.lat);
1084
+ const dLng = toRad(b.lng - a.lng);
1085
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
1086
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
1087
+ }
1088
+ function greatCirclePath(a, b, points = 64) {
1089
+ const \u03C61 = toRad(a.lat);
1090
+ const \u03BB1 = toRad(a.lng);
1091
+ const \u03C62 = toRad(b.lat);
1092
+ const \u03BB2 = toRad(b.lng);
1093
+ const d = haversineKm(a, b) / EARTH_RADIUS_KM;
1094
+ const out = [];
1095
+ for (let i = 0; i <= points; i++) {
1096
+ if (d === 0) {
1097
+ out.push({ lat: a.lat, lng: a.lng });
1098
+ continue;
1099
+ }
1100
+ const f = i / points;
1101
+ const A = Math.sin((1 - f) * d) / Math.sin(d);
1102
+ const B = Math.sin(f * d) / Math.sin(d);
1103
+ const x = A * Math.cos(\u03C61) * Math.cos(\u03BB1) + B * Math.cos(\u03C62) * Math.cos(\u03BB2);
1104
+ const y = A * Math.cos(\u03C61) * Math.sin(\u03BB1) + B * Math.cos(\u03C62) * Math.sin(\u03BB2);
1105
+ const z = A * Math.sin(\u03C61) + B * Math.sin(\u03C62);
1106
+ out.push({ lat: toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))), lng: toDeg(Math.atan2(y, x)) });
1107
+ }
1108
+ return out;
1109
+ }
1110
+ function makeSyntheticLeg(from, to, mode) {
1111
+ const speedKmh = (mode || "").toUpperCase() === "BOAT" ? BOAT_SPEED_KMH : FLIGHT_SPEED_KMH;
1112
+ const durationSec = haversineKm(from, to) / speedKmh * 3600;
1113
+ return {
1114
+ mode: mode.toUpperCase(),
1115
+ from,
1116
+ to,
1117
+ durationSec,
1118
+ steps: [{ path: greatCirclePath(from, to), durationSec }]
1119
+ };
1120
+ }
1121
+ var FLIGHT_SPEED_KMH, BOAT_SPEED_KMH, EARTH_RADIUS_KM, toRad, toDeg;
1122
+ var init_route_legs = __esm({
1123
+ "src/utils/route-legs.ts"() {
1124
+ "use strict";
1125
+ FLIGHT_SPEED_KMH = 850;
1126
+ BOAT_SPEED_KMH = 40;
1127
+ EARTH_RADIUS_KM = 6371;
1128
+ toRad = (d) => d * Math.PI / 180;
1129
+ toDeg = (d) => d * 180 / Math.PI;
1130
+ }
1131
+ });
1132
+
1133
+ // src/utils/directions.ts
1134
+ var directions_exports = {};
1135
+ __export(directions_exports, {
1136
+ legDurationSec: () => legDurationSec,
1137
+ routeLegTimings: () => routeLegTimings
1138
+ });
1139
+ function apiKey() {
1140
+ return typeof process !== "undefined" && process.env.GOOGLE_MAPS_API_KEY || "";
1141
+ }
1142
+ async function legDurationSec(from, to, mode, key = apiKey()) {
1143
+ const m = (mode || "").toUpperCase();
1144
+ if (!ROAD_MODES.has(m)) {
1145
+ return makeSyntheticLeg(from, to, m).durationSec;
1146
+ }
1147
+ if (!key) {
1148
+ return straightLineSec(from, to);
1149
+ }
1150
+ const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
1151
+ url.searchParams.set("origin", `${from.lat},${from.lng}`);
1152
+ url.searchParams.set("destination", `${to.lat},${to.lng}`);
1153
+ url.searchParams.set("mode", m.toLowerCase());
1154
+ url.searchParams.set("key", key);
1155
+ try {
1156
+ const res = await fetch(url);
1157
+ const data = await res.json();
1158
+ const dur = data?.routes?.[0]?.legs?.[0]?.duration?.value;
1159
+ if (typeof dur === "number" && dur > 0) return dur;
1160
+ return straightLineSec(from, to);
1161
+ } catch {
1162
+ return straightLineSec(from, to);
1163
+ }
1164
+ }
1165
+ function straightLineSec(from, to) {
1166
+ return haversineKm(from, to) / 50 * 3600;
1167
+ }
1168
+ async function routeLegTimings(waypoints, defaultMode = "DRIVING", key = apiKey()) {
1169
+ if (waypoints.length < 2) return [];
1170
+ const out = [];
1171
+ for (let i = 0; i < waypoints.length - 1; i++) {
1172
+ const from = waypoints[i];
1173
+ const to = waypoints[i + 1];
1174
+ const mode = (from.mode ?? defaultMode).toUpperCase();
1175
+ const durationSec = await legDurationSec(from, to, mode, key) ?? 0;
1176
+ out.push({ mode, from, to, durationSec });
1177
+ }
1178
+ return out;
1179
+ }
1180
+ var ROAD_MODES;
1181
+ var init_directions = __esm({
1182
+ "src/utils/directions.ts"() {
1183
+ "use strict";
1184
+ init_route_legs();
1185
+ ROAD_MODES = /* @__PURE__ */ new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]);
1186
+ }
1187
+ });
1188
+
1037
1189
  // src/player/pipeline.ts
1038
1190
  init_compiler();
1039
1191
 
@@ -9952,19 +10104,50 @@ function parseWaypoints(raw) {
9952
10104
  const bits = splitTokens(part.replace(/,/g, " "));
9953
10105
  const lat = Number(bits[0] ?? 0);
9954
10106
  const lng = Number(bits[1] ?? 0);
9955
- const labelRaw = bits[2];
9956
- const label = labelRaw ? unquote(labelRaw) : void 0;
9957
- return { lat, lng, label };
10107
+ let mode;
10108
+ const values = [];
10109
+ for (const tok of bits.slice(2)) {
10110
+ const value2 = unquote(tok);
10111
+ if (!isQuoted(tok) && value2 && KNOWN_TRAVEL_MODES.has(value2.toUpperCase())) {
10112
+ mode = value2.toUpperCase();
10113
+ continue;
10114
+ }
10115
+ values.push(value2);
10116
+ }
10117
+ const label = values[0] || void 0;
10118
+ const media = values[1] || void 0;
10119
+ return { lat, lng, label, media, mode };
9958
10120
  });
9959
10121
  }
10122
+ var KNOWN_TRAVEL_MODES = /* @__PURE__ */ new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT", "FLIGHT", "BOAT"]);
10123
+ function rewriteTweenExprs(s) {
10124
+ return s.replace(
10125
+ /tween\(\s*([^,()]+?)\s*,\s*([^,()]+?)\s*(?:,\s*([^()]+?))?\s*\)/g,
10126
+ (_match, fromRaw, toRaw, easingRaw) => {
10127
+ const scalar = (v) => {
10128
+ const t = v.trim();
10129
+ if (/^[+-]?(\d+(\.\d+)?|\.\d+)$/.test(t)) return t;
10130
+ if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return t;
10131
+ if (t === "true" || t === "false" || t === "null") return t;
10132
+ return JSON.stringify(t);
10133
+ };
10134
+ const items = [scalar(fromRaw), scalar(toRaw)];
10135
+ if (easingRaw !== void 0 && easingRaw.trim()) {
10136
+ items.push(scalar(easingRaw));
10137
+ }
10138
+ return `{"__tween":[${items.join(",")}]}`;
10139
+ }
10140
+ );
10141
+ }
9960
10142
  function parseProps(raw) {
9961
10143
  const s = raw.trim();
9962
10144
  if (!s.startsWith("{") && !s.startsWith("[")) return {};
9963
10145
  if (!s.endsWith("}") && !s.endsWith("]")) return {};
10146
+ const withTweens = rewriteTweenExprs(s);
9964
10147
  try {
9965
- return JSON.parse(s);
10148
+ return JSON.parse(withTweens);
9966
10149
  } catch {
9967
- let normalized = s.replace(
10150
+ let normalized = withTweens.replace(
9968
10151
  /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
9969
10152
  '$1"$2":'
9970
10153
  );
@@ -9980,7 +10163,7 @@ function parseProps(raw) {
9980
10163
  return JSON.parse(normalized);
9981
10164
  } catch {
9982
10165
  try {
9983
- const result = (0, eval)("(" + s + ")");
10166
+ const result = (0, eval)("(" + withTweens + ")");
9984
10167
  return typeof result === "object" && result !== null ? result : {};
9985
10168
  } catch {
9986
10169
  return {};
@@ -10233,6 +10416,10 @@ function preserveVariantAttrs(node2, attrs) {
10233
10416
  "mapType",
10234
10417
  "data",
10235
10418
  "prompt",
10419
+ "view",
10420
+ "camera",
10421
+ "cinematic",
10422
+ "streetView",
10236
10423
  "name",
10237
10424
  "title",
10238
10425
  "transition",
@@ -10455,6 +10642,7 @@ function parseNodeLine(content3, lineNum) {
10455
10642
  waypoints: attrs.waypoints ?? [],
10456
10643
  duration: attrs.duration,
10457
10644
  start: attrs.start,
10645
+ view: attrs.view,
10458
10646
  routeMarker: attrs.routeMarker,
10459
10647
  travelMode: attrs.travelMode,
10460
10648
  routeColor: attrs.routeColor,
@@ -10462,6 +10650,9 @@ function parseNodeLine(content3, lineNum) {
10462
10650
  zoom: attrs.zoom,
10463
10651
  center: attrs.center,
10464
10652
  mapType: attrs.mapType,
10653
+ camera: attrs.camera,
10654
+ cinematic: attrs.cinematic,
10655
+ streetView: attrs.streetView,
10465
10656
  language: attrs.language ?? attrs.lang,
10466
10657
  region: attrs.region,
10467
10658
  instruction: attrs.instruction,
@@ -10630,7 +10821,7 @@ function processMDASTListItem(item, parent, lines) {
10630
10821
  Object.assign(node2, attrs);
10631
10822
  }
10632
10823
  } else if (child.type === "list") {
10633
- if (node2.type === "series" || node2.type === "parallel" || node2.type === "transitionSeries" || node2.type === "effect" || node2.type === "include" || node2.type === "rhythm") {
10824
+ if (node2.type === "series" || node2.type === "parallel" || node2.type === "transitionSeries" || node2.type === "effect" || node2.type === "include" || node2.type === "rhythm" || node2.type === "map") {
10634
10825
  for (const subItem of child.children) {
10635
10826
  processMDASTListItem(subItem, node2, lines);
10636
10827
  }
@@ -10764,7 +10955,22 @@ function probeDuration(src, baseDir) {
10764
10955
  }
10765
10956
  function resolveSrc(src, baseDir) {
10766
10957
  if (/^(https?:|file:|\/)/.test(src)) return src;
10767
- return resolvePath(baseDir ?? process.cwd(), src);
10958
+ return resolvePath(baseDir, src);
10959
+ }
10960
+ function relativizeAssetsUnder(node2, baseDir) {
10961
+ if (!node2 || typeof node2 !== "object") return node2;
10962
+ if (Array.isArray(node2)) return node2.map((v) => relativizeAssetsUnder(v, baseDir));
10963
+ const out = {};
10964
+ for (const [k, v] of Object.entries(node2)) {
10965
+ if (typeof v === "string" && v.startsWith(baseDir + "/")) {
10966
+ out[k] = v.slice(baseDir.length + 1);
10967
+ } else if (v && typeof v === "object") {
10968
+ out[k] = relativizeAssetsUnder(v, baseDir);
10969
+ } else {
10970
+ out[k] = v;
10971
+ }
10972
+ }
10973
+ return out;
10768
10974
  }
10769
10975
  var COMMON_RESOLUTIONS = [
10770
10976
  { width: 1920, height: 1080 },
@@ -10809,7 +11015,7 @@ function resolveMediaSrc(src, targetWidth, targetHeight, baseDir) {
10809
11015
  console.warn(` \u26A0 No matching media file for "${src}" at ${targetWidth}x${targetHeight}`);
10810
11016
  return exactAbs;
10811
11017
  }
10812
- async function resolveMediaSrcs(root, options = {}) {
11018
+ async function resolveMediaSrcs(root, options) {
10813
11019
  const clone = JSON.parse(JSON.stringify(root));
10814
11020
  const baseDir = options.baseDir;
10815
11021
  const targetWidth = clone.width ?? 1080;
@@ -10830,7 +11036,7 @@ async function resolveMediaSrcs(root, options = {}) {
10830
11036
  });
10831
11037
  return clone;
10832
11038
  }
10833
- async function resolveMediaDurations(root, options = {}) {
11039
+ async function resolveMediaDurations(root, options) {
10834
11040
  const clone = JSON.parse(JSON.stringify(root));
10835
11041
  const baseDir = options.baseDir;
10836
11042
  walkDown(clone, (node2) => {
@@ -10910,13 +11116,15 @@ async function resolveScripts(root, options) {
10910
11116
  const clone = JSON.parse(JSON.stringify(root));
10911
11117
  mkdirSync2(options.outputDir, { recursive: true });
10912
11118
  const allScriptNodes = [];
10913
- walkDown(clone, (node2) => {
10914
- if (node2.type !== "audio") return;
10915
- if (!node2.script || typeof node2.script !== "string") return;
10916
- if (node2.src) return;
10917
- const id = node2.id ?? `audio-${allScriptNodes.length}`;
10918
- allScriptNodes.push({ node: node2, id });
10919
- });
11119
+ const collect = (node2, inherited) => {
11120
+ const ttsOverride = typeof node2.tts === "string" && node2.tts.length > 0 ? node2.tts : inherited;
11121
+ if (node2.type === "audio" && node2.script && typeof node2.script === "string" && !node2.src) {
11122
+ const id = node2.id ?? `audio-${allScriptNodes.length}`;
11123
+ allScriptNodes.push({ node: node2, id, ttsOverride });
11124
+ }
11125
+ for (const c of node2.children ?? []) collect(c, ttsOverride);
11126
+ };
11127
+ collect(clone);
10920
11128
  const totalScripts = allScriptNodes.length;
10921
11129
  let scriptsDone = 0;
10922
11130
  let cacheHits = 0;
@@ -10924,9 +11132,9 @@ async function resolveScripts(root, options) {
10924
11132
  if (totalScripts > 0) {
10925
11133
  console.log(` \u{1F50A} TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
10926
11134
  }
10927
- for (const { node: node2, id } of allScriptNodes) {
11135
+ for (const { node: node2, id, ttsOverride } of allScriptNodes) {
10928
11136
  scriptsDone++;
10929
- let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
11137
+ let ttsCli = ttsOverride ?? options.ttsCli ?? DEFAULT_TTS_CLI;
10930
11138
  if (node2.speaker && clone.voices) {
10931
11139
  const speakerVoice = clone.voices[node2.speaker];
10932
11140
  if (speakerVoice) {
@@ -11242,9 +11450,9 @@ function applyStoryboardOverrides(root, options) {
11242
11450
  }
11243
11451
  return clone;
11244
11452
  }
11245
- async function resolveIncludes(root, options = {}) {
11453
+ async function resolveIncludes(root, options) {
11246
11454
  const clone = JSON.parse(JSON.stringify(root));
11247
- const baseDir = options.baseDir ?? process.cwd();
11455
+ const baseDir = options.baseDir;
11248
11456
  const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
11249
11457
  mkdirSync2(outputDir, { recursive: true });
11250
11458
  function extractImportEntriesFromRaw(raw) {
@@ -11323,7 +11531,7 @@ async function resolveIncludes(root, options = {}) {
11323
11531
  }
11324
11532
  return clone;
11325
11533
  }
11326
- async function resolveAll2(root, options = {}) {
11534
+ async function resolveAll2(root, options) {
11327
11535
  let result = root;
11328
11536
  if (result.seed == null && options.seed == null && options.sourcePath) {
11329
11537
  const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
@@ -11391,8 +11599,89 @@ async function resolveAll2(root, options = {}) {
11391
11599
  mergedOutputDir: options.subtitleOutputDir
11392
11600
  });
11393
11601
  }
11602
+ result = await resolveRouteStops(result);
11603
+ result = relativizeAssetsUnder(result, options.baseDir);
11394
11604
  return result;
11395
11605
  }
11606
+ var DEFAULT_CHILD_SECONDS = 3;
11607
+ var DEFAULT_MAP_DRIVE_SECONDS = 10;
11608
+ async function resolveRouteStops(root) {
11609
+ const { routeLegTimings: routeLegTimings2 } = await Promise.resolve().then(() => (init_directions(), directions_exports));
11610
+ async function resolveMap(node2) {
11611
+ if (!node2 || typeof node2 !== "object") return;
11612
+ if (Array.isArray(node2)) {
11613
+ for (const n of node2) await resolveMap(n);
11614
+ return;
11615
+ }
11616
+ if (node2.type === "map" && Array.isArray(node2.children) && node2.children.length) {
11617
+ const children = node2.children;
11618
+ const anchored = children.filter((c) => c && c.at);
11619
+ if (anchored.length) {
11620
+ await timeMapChildren(node2, anchored, routeLegTimings2);
11621
+ }
11622
+ }
11623
+ for (const child of node2.children ?? []) await resolveMap(child);
11624
+ }
11625
+ await resolveMap(root.children);
11626
+ return root;
11627
+ }
11628
+ async function timeMapChildren(map, anchored, routeLegTimings2) {
11629
+ const waypoints = map.waypoints ?? [];
11630
+ if (waypoints.length < 2) return;
11631
+ const legs = await routeLegTimings2(waypoints, map.travelMode ?? "DRIVING");
11632
+ const totalDrive = legs.reduce((s, l) => s + (l.durationSec || 0), 0);
11633
+ if (totalDrive <= 0) return;
11634
+ const childDuration = (c) => typeof c.duration === "number" && c.duration > 0 ? c.duration : DEFAULT_CHILD_SECONDS;
11635
+ const dwellAt = /* @__PURE__ */ new Map();
11636
+ for (const c of anchored) {
11637
+ const d = childDuration(c);
11638
+ dwellAt.set(c.at, Math.max(dwellAt.get(c.at) ?? 0, d));
11639
+ }
11640
+ const totalDwell = [...dwellAt.values()].reduce((s, d) => s + d, 0);
11641
+ const hasOwnDur = typeof map.duration === "number" || typeof map.endAt === "number";
11642
+ const totalDwellBudget = Math.max(0, map.duration ?? map.endAt ?? DEFAULT_MAP_DRIVE_SECONDS);
11643
+ const mapDur = hasOwnDur ? map.duration ?? map.endAt ?? totalDwellBudget + totalDwell : totalDwellBudget + totalDwell;
11644
+ const driveBudget = Math.max(0.1, mapDur - totalDwell);
11645
+ const scale = driveBudget / totalDrive;
11646
+ const arrivalAt = /* @__PURE__ */ new Map();
11647
+ const wp0 = waypoints[0];
11648
+ arrivalAt.set(wp0.label ?? String(wp0.lat) + "," + String(wp0.lng), 0);
11649
+ let cumDrive = 0;
11650
+ let cumDwells = 0;
11651
+ for (let i = 0; i < legs.length; i++) {
11652
+ cumDrive += legs[i].durationSec || 0;
11653
+ const wp = waypoints[i + 1];
11654
+ const arrival = cumDrive * scale + cumDwells;
11655
+ arrivalAt.set(wp.label ?? String(wp.lat) + "," + String(wp.lng), arrival);
11656
+ if (dwellAt.has(wp.label)) cumDwells += dwellAt.get(wp.label);
11657
+ }
11658
+ const schedule = [];
11659
+ for (const c of anchored) {
11660
+ const arrival = arrivalAt.get(c.at);
11661
+ if (arrival == null) {
11662
+ console.warn(` \u26A0 map child at:"${c.at}" \u2014 no waypoint with that label; renders full-screen`);
11663
+ continue;
11664
+ }
11665
+ if (typeof c.start === "number") {
11666
+ schedule.push(` \u{1F6D1} ${c.at}: manual ${c.start.toFixed(1)}s (unchanged)`);
11667
+ continue;
11668
+ }
11669
+ const dur = childDuration(c);
11670
+ c.start = arrival;
11671
+ c.end = arrival + dur;
11672
+ schedule.push(` \u{1F6D1} ${c.at}: ${arrival.toFixed(1)}s \u2192 ${(arrival + dur).toFixed(1)}s (${dur.toFixed(1)}s)`);
11673
+ }
11674
+ if (schedule.length) {
11675
+ console.log(` \u{1F5FA} route stops (${mapDur.toFixed(1)}s total):`);
11676
+ for (const line of schedule) console.log(line);
11677
+ }
11678
+ if (!hasOwnDur) {
11679
+ map.duration = mapDur;
11680
+ } else if ((map.duration ?? 0) < mapDur) {
11681
+ map.duration = mapDur;
11682
+ console.log(` \u{1F5FA} extended map duration \u2192 ${mapDur.toFixed(1)}s (drives + dwells)`);
11683
+ }
11684
+ }
11396
11685
 
11397
11686
  // src/player/pipeline.ts
11398
11687
  function isDescriptiveRoot(data) {
@@ -11408,7 +11697,7 @@ function isDescriptiveRoot(data) {
11408
11697
  )) return true;
11409
11698
  return false;
11410
11699
  }
11411
- async function resolveAndCompile(data, options = {}) {
11700
+ async function resolveAndCompile(data, options) {
11412
11701
  const resolved = await resolveAll2(data, {
11413
11702
  sourcePath: options.sourcePath,
11414
11703
  baseDir: options.baseDir,
@@ -11427,7 +11716,7 @@ async function resolveAndCompile(data, options = {}) {
11427
11716
  });
11428
11717
  return compiled;
11429
11718
  }
11430
- async function resolveAndCompileMarkdown(markdown, options = {}) {
11719
+ async function resolveAndCompileMarkdown(markdown, options) {
11431
11720
  const descriptive = parseMarkdownDescriptive(markdown);
11432
11721
  return resolveAndCompile(descriptive, options);
11433
11722
  }
@@ -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);