@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
@@ -101,6 +101,7 @@ function preserveVariantAttrs(node: Record<string, unknown>, attrs: Record<strin
101
101
  "foreground", "visible", "isBackground", "instruction", "style", "effects", "on",
102
102
  "spots", "waypoints", "routeColor", "routeWeight", "routeMarker",
103
103
  "travelMode", "zoom", "center", "mapType", "data", "prompt",
104
+ "view", "camera", "cinematic", "streetView",
104
105
  "name", "title", "transition", "transitionTime", "layout",
105
106
  "componentName", "props", "speaker",
106
107
  ]);
@@ -338,6 +339,7 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
338
339
  waypoints: (attrs.waypoints as DescriptiveMapWaypoint[] | undefined) ?? [],
339
340
  duration: attrs.duration as any,
340
341
  start: attrs.start as any,
342
+ view: attrs.view as any,
341
343
  routeMarker: attrs.routeMarker as any,
342
344
  travelMode: attrs.travelMode as any,
343
345
  routeColor: attrs.routeColor as any,
@@ -345,6 +347,9 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
345
347
  zoom: attrs.zoom as any,
346
348
  center: attrs.center as any,
347
349
  mapType: attrs.mapType as any,
350
+ camera: attrs.camera as any,
351
+ cinematic: attrs.cinematic as any,
352
+ streetView: attrs.streetView as any,
348
353
  language: (attrs.language as any) ?? (attrs.lang as any),
349
354
  region: attrs.region as any,
350
355
  instruction: attrs.instruction as any,
@@ -583,7 +588,8 @@ function processMDASTListItem(item: any, parent: ParentNode, lines: string[]): v
583
588
  node.type === "transitionSeries" ||
584
589
  node.type === "effect" ||
585
590
  node.type === "include" ||
586
- node.type === "rhythm"
591
+ node.type === "rhythm" ||
592
+ node.type === "map"
587
593
  ) {
588
594
  for (const subItem of child.children) {
589
595
  processMDASTListItem(subItem, node as ParentNode, lines);
@@ -755,7 +755,7 @@ describe("resolveMediaDurations", () => {
755
755
  { type: "video", src: "nonexistent.mp4", duration: 5 },
756
756
  ],
757
757
  };
758
- const result = await resolveMediaDurations(root);
758
+ const result = await resolveMediaDurations(root, { baseDir: tmpDir });
759
759
  expect(result).not.toBe(root);
760
760
  expect(result.children[0]!.duration).toBe(5);
761
761
  });
@@ -767,7 +767,7 @@ describe("resolveMediaDurations", () => {
767
767
  { type: "video", src: "anything.mp4", duration: 7 },
768
768
  ],
769
769
  };
770
- const result = await resolveMediaDurations(root);
770
+ const result = await resolveMediaDurations(root, { baseDir: tmpDir });
771
771
  expect(result.children[0]!.duration).toBe(7);
772
772
  });
773
773
 
@@ -778,7 +778,7 @@ describe("resolveMediaDurations", () => {
778
778
  { type: "video", src: "anything.mp4", startFrom: 2, endAt: 5 },
779
779
  ],
780
780
  };
781
- const result = await resolveMediaDurations(root);
781
+ const result = await resolveMediaDurations(root, { baseDir: tmpDir });
782
782
  expect((result.children[0] as any).endAt).toBe(5);
783
783
  expect((result.children[0] as any).duration).toBeUndefined();
784
784
  });
@@ -791,7 +791,7 @@ describe("resolveMediaDurations", () => {
791
791
  { type: "image", src: "photo.jpg" },
792
792
  ],
793
793
  };
794
- const result = await resolveMediaDurations(root, { skip: /\.jpg$/ });
794
+ const result = await resolveMediaDurations(root, { baseDir: tmpDir, skip: /\.jpg$/ });
795
795
  // .jpg skipped, .mp4 tried but file doesn't exist so duration stays undefined
796
796
  expect(result.children[0]!.duration).toBeUndefined();
797
797
  expect(result.children[1]!.duration).toBeUndefined();
@@ -805,7 +805,7 @@ describe("resolveMediaDurations", () => {
805
805
  ],
806
806
  };
807
807
  const originalChildren = root.children[0];
808
- const result = await resolveMediaDurations(root);
808
+ const result = await resolveMediaDurations(root, { baseDir: tmpDir });
809
809
  expect(result).not.toBe(root);
810
810
  expect(root.children[0]).toBe(originalChildren);
811
811
  });
@@ -962,3 +962,101 @@ describe("resolveIncludes — variant overrides", () => {
962
962
  expect(incNode.durationInSeconds).toBe(2);
963
963
  });
964
964
  });
965
+
966
+ // ── resolveRouteStops (map overlay auto-timing) ─────────────────────────────
967
+
968
+ describe("resolveRouteStops", () => {
969
+ it("auto-times map children from synthetic FLIGHT leg durations (no network)", async () => {
970
+ const { resolveRouteStops } = await import("./resolve");
971
+ const root: any = {
972
+ type: "root",
973
+ children: [
974
+ {
975
+ type: "scene",
976
+ children: [
977
+ {
978
+ type: "map",
979
+ view: "route",
980
+ travelMode: "FLIGHT",
981
+ waypoints: [
982
+ { lat: 37.7749, lng: -122.4194, label: "SF" },
983
+ { lat: 34.0522, lng: -118.2437, label: "LA" },
984
+ { lat: 33.9425, lng: -118.4081, label: "PIER" },
985
+ ],
986
+ children: [
987
+ { type: "image", at: "LA", duration: 4 },
988
+ { type: "image", at: "PIER", duration: 2 },
989
+ ],
990
+ },
991
+ ],
992
+ },
993
+ ],
994
+ };
995
+
996
+ await resolveRouteStops(root);
997
+
998
+ const map = root.children[0].children[0];
999
+ const [la, pier] = map.children;
1000
+
1001
+ // Map duration = default drive budget (10s) + dwells (4 + 2) = 16s.
1002
+ expect(map.duration).toBeCloseTo(16, 1);
1003
+
1004
+ // First child arrives at LA after leg 1 (SF→LA ≈ 2367s of 2481s total)
1005
+ // scaled into the 10s drive budget → ≈ 9.5s, dwells 4s.
1006
+ expect(la.start).toBeGreaterThan(9);
1007
+ expect(la.start).toBeLessThan(10);
1008
+ expect(la.end - la.start).toBeCloseTo(4, 6);
1009
+
1010
+ // Second child arrives at PIER after leg 2 + the LA dwell → ≈ 14s.
1011
+ expect(pier.start).toBeGreaterThan(13.5);
1012
+ expect(pier.start).toBeLessThan(14.5);
1013
+ expect(pier.end - pier.start).toBeCloseTo(2, 6);
1014
+
1015
+ // Children with explicit `start` are left untouched.
1016
+ const explicit: any = {
1017
+ type: "map",
1018
+ view: "route",
1019
+ travelMode: "FLIGHT",
1020
+ waypoints: [
1021
+ { lat: 37.7749, lng: -122.4194, label: "SF" },
1022
+ { lat: 34.0522, lng: -118.2437, label: "LA" },
1023
+ ],
1024
+ children: [{ type: "image", at: "LA", duration: 4, start: 3, end: 7 }],
1025
+ };
1026
+ const explicitRoot: any = { type: "root", children: [explicit] };
1027
+ await resolveRouteStops(explicitRoot);
1028
+ expect(explicit.children[0].start).toBe(3);
1029
+ expect(explicit.children[0].end).toBe(7);
1030
+ });
1031
+
1032
+ it("respects an explicit map duration (drives shrink to fit drives+dwells)", async () => {
1033
+ const { resolveRouteStops } = await import("./resolve");
1034
+ const root: any = {
1035
+ type: "root",
1036
+ children: [
1037
+ {
1038
+ type: "map",
1039
+ view: "route",
1040
+ duration: 20,
1041
+ travelMode: "FLIGHT",
1042
+ waypoints: [
1043
+ { lat: 37.7749, lng: -122.4194, label: "SF" },
1044
+ { lat: 34.0522, lng: -118.2437, label: "LA" },
1045
+ { lat: 33.9425, lng: -118.4081, label: "PIER" },
1046
+ ],
1047
+ children: [{ type: "image", at: "LA", duration: 4 }],
1048
+ },
1049
+ ],
1050
+ };
1051
+
1052
+ await resolveRouteStops(root);
1053
+
1054
+ const map = root.children[0];
1055
+ expect(map.duration).toBe(20);
1056
+ // Dwell 4s stays; drives get the remaining 16s budget proportionally.
1057
+ const la = map.children[0];
1058
+ expect(la.start).toBeGreaterThan(14);
1059
+ expect(la.start).toBeLessThan(17);
1060
+ expect(la.end - la.start).toBeCloseTo(4, 6);
1061
+ });
1062
+ });
@@ -46,8 +46,9 @@ function firstWords(text: string, n: number): string {
46
46
  }
47
47
 
48
48
  export interface ResolveMediaOptions {
49
- /** Base directory for resolving relative src paths (default: cwd) */
50
- baseDir?: string;
49
+ /** Source file's folder — every asset path is resolved/emitted relative to it.
50
+ * Always set by the CLI/server (dirname of the source file); no fallback. */
51
+ baseDir: string;
51
52
  /** Skip nodes whose src matches this regex */
52
53
  skip?: RegExp;
53
54
  }
@@ -56,7 +57,7 @@ export interface ResolveMediaOptions {
56
57
  * Probe actual media duration via ffprobe.
57
58
  * Returns duration in seconds, or null if probe fails.
58
59
  */
59
- function probeDuration(src: string, baseDir?: string): number | null {
60
+ function probeDuration(src: string, baseDir: string): number | null {
60
61
  const absPath = resolveSrc(src, baseDir);
61
62
  try {
62
63
  const out = execSync(
@@ -70,9 +71,37 @@ function probeDuration(src: string, baseDir?: string): number | null {
70
71
  }
71
72
  }
72
73
 
73
- function resolveSrc(src: string, baseDir?: string): string {
74
+ function resolveSrc(src: string, baseDir: string): string {
74
75
  if (/^(https?:|file:|\/)/.test(src)) return src;
75
- return resolvePath(baseDir ?? process.cwd(), src);
76
+ return resolvePath(baseDir, src);
77
+ }
78
+
79
+ /**
80
+ * Convert every generated asset path that lives under `baseDir` (the source
81
+ * .md folder) into a path relative to it, so the compiled JSON references
82
+ * every asset from the md folder's perspective. Render serves that folder via
83
+ * --public-dir and the preview server serves it as the document root — no
84
+ * render-time normalization needed. Paths outside baseDir (or remote) stay
85
+ * unchanged.
86
+ *
87
+ * Applied once at the end of resolveAll — all resolvers keep emitting
88
+ * absolute paths internally (needed for ffprobe/whisper), and this single
89
+ * walk relativizes them for the final JSON.
90
+ */
91
+ function relativizeAssetsUnder(node: any, baseDir: string): any {
92
+ if (!node || typeof node !== "object") return node;
93
+ if (Array.isArray(node)) return node.map((v) => relativizeAssetsUnder(v, baseDir));
94
+ const out: Record<string, any> = {};
95
+ for (const [k, v] of Object.entries(node)) {
96
+ if (typeof v === "string" && v.startsWith(baseDir + "/")) {
97
+ out[k] = v.slice(baseDir.length + 1);
98
+ } else if (v && typeof v === "object") {
99
+ out[k] = relativizeAssetsUnder(v, baseDir);
100
+ } else {
101
+ out[k] = v;
102
+ }
103
+ }
104
+ return out;
76
105
  }
77
106
 
78
107
  /**
@@ -111,7 +140,7 @@ export function resolveMediaSrc(
111
140
  src: string,
112
141
  targetWidth: number,
113
142
  targetHeight: number,
114
- baseDir?: string,
143
+ baseDir: string,
115
144
  ): string {
116
145
  // If no pattern, direct resolve
117
146
  if (!src.includes("${")) {
@@ -157,7 +186,7 @@ export function resolveMediaSrc(
157
186
  */
158
187
  export async function resolveMediaSrcs(
159
188
  root: DescriptiveRoot,
160
- options: ResolveMediaOptions = {},
189
+ options: ResolveMediaOptions,
161
190
  ): Promise<DescriptiveRoot> {
162
191
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
163
192
  const baseDir = options.baseDir;
@@ -190,7 +219,7 @@ export async function resolveMediaSrcs(
190
219
  */
191
220
  export async function resolveMediaDurations(
192
221
  root: DescriptiveRoot,
193
- options: ResolveMediaOptions = {},
222
+ options: ResolveMediaOptions,
194
223
  ): Promise<DescriptiveRoot> {
195
224
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
196
225
  const baseDir = options.baseDir;
@@ -355,15 +384,20 @@ export async function resolveScripts(
355
384
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
356
385
  mkdirSync(options.outputDir, { recursive: true });
357
386
 
358
- // Collect all audio nodes that have script text but no src yet
359
- const allScriptNodes: Array<{ node: any; id: string }> = [];
360
- walkDown(clone as any, (node) => {
361
- if (node.type !== "audio") return;
362
- if (!node.script || typeof node.script !== "string") return;
363
- if (node.src) return; // already has real source
364
- const id = node.id ?? `audio-${allScriptNodes.length}`;
365
- allScriptNodes.push({ node, id });
366
- });
387
+ // Collect all audio nodes that have script text but no src yet. Each node
388
+ // carries the nearest ancestor's `tts` override (root.tts by default), so a
389
+ // scene-level `tts` wins over root while other scenes keep root's voice.
390
+ const allScriptNodes: Array<{ node: any; id: string; ttsOverride?: string }> = [];
391
+ const collect = (node: any, inherited?: string): void => {
392
+ const ttsOverride =
393
+ typeof node.tts === "string" && node.tts.length > 0 ? node.tts : inherited;
394
+ if (node.type === "audio" && node.script && typeof node.script === "string" && !node.src) {
395
+ const id = node.id ?? `audio-${allScriptNodes.length}`;
396
+ allScriptNodes.push({ node, id, ttsOverride });
397
+ }
398
+ for (const c of node.children ?? []) collect(c, ttsOverride);
399
+ };
400
+ collect(clone as any);
367
401
 
368
402
  const totalScripts = allScriptNodes.length;
369
403
  let scriptsDone = 0;
@@ -374,10 +408,10 @@ export async function resolveScripts(
374
408
  console.log(` 🔊 TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
375
409
  }
376
410
 
377
- for (const { node, id } of allScriptNodes) {
411
+ for (const { node, id, ttsOverride } of allScriptNodes) {
378
412
  scriptsDone++;
379
- // TTS CLI from root config only
380
- let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
413
+ // TTS CLI: nearest ancestor `tts` (root.tts by default) → options → default
414
+ let ttsCli = ttsOverride ?? options.ttsCli ?? DEFAULT_TTS_CLI;
381
415
 
382
416
  // Per-speaker voice appends extra CLI flags from root voices config
383
417
  if (node.speaker && clone.voices) {
@@ -411,7 +445,8 @@ export async function resolveScripts(
411
445
  }
412
446
  if (!generated) continue;
413
447
 
414
- // Set resolved src (normalize to absolute for reliable probing later)
448
+ // Set resolved src (absolute for reliable probing; the final
449
+ // relativizeAssetsUnder pass in resolveAll makes it md-folder-relative)
415
450
  node.src = resolvePath(generated);
416
451
  delete node.script;
417
452
  }
@@ -485,6 +520,8 @@ export async function resolveSubtitles(
485
520
  const effectiveOffset = nodeStart + actionStart;
486
521
 
487
522
  if (node.type === "audio" && node.src) {
523
+ // node.src is still absolute here (relativization happens at the end of
524
+ // resolveAll), so whisper/existsSync can read the file directly.
488
525
  clips.push({ audioSrc: node.src, offset: effectiveOffset, speaker: node.speaker });
489
526
  }
490
527
 
@@ -937,10 +974,10 @@ export interface ResolveAllOptions extends ResolveMediaOptions {
937
974
  */
938
975
  export async function resolveIncludes(
939
976
  root: DescriptiveRoot,
940
- options: ResolveAllOptions = {},
977
+ options: ResolveAllOptions,
941
978
  ): Promise<DescriptiveRoot> {
942
979
  const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
943
- const baseDir = options.baseDir ?? process.cwd();
980
+ const baseDir = options.baseDir;
944
981
  const outputDir = options.includeOutputDir ?? join(baseDir, ".markcut", "generated", "includes");
945
982
  mkdirSync(outputDir, { recursive: true });
946
983
 
@@ -1076,7 +1113,7 @@ export async function resolveIncludes(
1076
1113
  */
1077
1114
  export async function resolveAll(
1078
1115
  root: DescriptiveRoot,
1079
- options: ResolveAllOptions = {},
1116
+ options: ResolveAllOptions,
1080
1117
  ): Promise<DescriptiveRoot> {
1081
1118
  let result = root;
1082
1119
 
@@ -1180,5 +1217,151 @@ export async function resolveAll(
1180
1217
  });
1181
1218
  }
1182
1219
 
1220
+ // Step 6: Auto-time map overlay children (at:"Waypoint") from the route —
1221
+ // arrival + dwell, so the author writes no timing. Only maps with anchored
1222
+ // children lacking explicit start trigger Directions calls.
1223
+ result = await resolveRouteStops(result);
1224
+
1225
+ // Final step: emit every generated asset path relative to the source .md
1226
+ // folder so the compiled JSON carries md-folder-relative paths. Render
1227
+ // serves that folder via --public-dir and the preview server serves it as
1228
+ // the document root — no render-time normalization needed. baseDir is
1229
+ // always the source file's folder (set by the CLI/server) — no fallback.
1230
+ result = relativizeAssetsUnder(result, options.baseDir);
1231
+
1183
1232
  return result;
1184
1233
  }
1234
+
1235
+ // ── Route stops (auto-time map overlay children) ─────────────────────────
1236
+
1237
+ /** Default duration for a map child with no explicit duration / audio. */
1238
+ const DEFAULT_CHILD_SECONDS = 3;
1239
+
1240
+ /** Default drive budget (seconds) when the map has no explicit duration —
1241
+ * drives fill this, dwells are added on top. Real Directions durations are
1242
+ * only used for leg ratios. */
1243
+ const DEFAULT_MAP_DRIVE_SECONDS = 10;
1244
+
1245
+ /**
1246
+ * For every `map` node with children anchored via `at:"WaypointLabel"`, derive
1247
+ * each child's `start`/`end` from the route's travel times:
1248
+ *
1249
+ * - leg durations from Directions REST (road modes) or haversine cruise
1250
+ * speed (FLIGHT/BOAT synthetic) — same math the renderer uses
1251
+ * - the pin holds (dwells) at a waypoint while its children play
1252
+ * - drives are scaled proportionally to leg duration across the remaining
1253
+ * budget, so arrivals match the renderer's `routePositionAtLegs`
1254
+ *
1255
+ * Children that already have an explicit `start` are left untouched (but still
1256
+ * contribute to the pin's pause window). When the map has no explicit duration,
1257
+ * it is set to drives + dwells.
1258
+ */
1259
+ export async function resolveRouteStops(
1260
+ root: DescriptiveRoot,
1261
+ ): Promise<DescriptiveRoot> {
1262
+ const { routeLegTimings } = await import("../utils/directions");
1263
+
1264
+ async function resolveMap(node: any): Promise<void> {
1265
+ if (!node || typeof node !== "object") return;
1266
+ if (Array.isArray(node)) {
1267
+ for (const n of node) await resolveMap(n);
1268
+ return;
1269
+ }
1270
+
1271
+ if (node.type === "map" && Array.isArray(node.children) && node.children.length) {
1272
+ const children = node.children as any[];
1273
+ const anchored = children.filter((c) => c && c.at);
1274
+ if (anchored.length) {
1275
+ await timeMapChildren(node, anchored, routeLegTimings);
1276
+ }
1277
+ }
1278
+
1279
+ for (const child of node.children ?? []) await resolveMap(child);
1280
+ }
1281
+
1282
+ await resolveMap((root as any).children);
1283
+ return root;
1284
+ }
1285
+
1286
+ async function timeMapChildren(
1287
+ map: any,
1288
+ anchored: any[],
1289
+ routeLegTimings: (waypoints: any[], defaultMode?: string) => Promise<{ durationSec: number }[]>,
1290
+ ): Promise<void> {
1291
+ const waypoints = map.waypoints ?? [];
1292
+ if (waypoints.length < 2) return;
1293
+
1294
+ // 1. Per-leg travel times.
1295
+ const legs = await routeLegTimings(waypoints, map.travelMode ?? "DRIVING");
1296
+ const totalDrive = legs.reduce((s, l) => s + (l.durationSec || 0), 0);
1297
+ if (totalDrive <= 0) return;
1298
+
1299
+ // 2. Per-waypoint dwell = max duration of anchored children there.
1300
+ const childDuration = (c: any): number =>
1301
+ typeof c.duration === "number" && c.duration > 0 ? c.duration : DEFAULT_CHILD_SECONDS;
1302
+ const dwellAt = new Map<string, number>();
1303
+ for (const c of anchored) {
1304
+ const d = childDuration(c);
1305
+ dwellAt.set(c.at, Math.max(dwellAt.get(c.at) ?? 0, d));
1306
+ }
1307
+ const totalDwell = [...dwellAt.values()].reduce((s, d) => s + d, 0);
1308
+
1309
+ // 3. Timeline: drives scaled proportionally across the drive budget; dwells
1310
+ // inserted after each arrival. arrival(i) = S·cumDrive(i)/D + cumDwells(<i).
1311
+ // Real Directions durations only provide RATIOS — the video length is the
1312
+ // author's map `duration` (or a default), so a 40-minute drive still plays
1313
+ // as a short clip with the pin timing proportional to each leg.
1314
+ const hasOwnDur = typeof map.duration === "number" || typeof map.endAt === "number";
1315
+ const totalDwellBudget = Math.max(0, map.duration ?? map.endAt ?? DEFAULT_MAP_DRIVE_SECONDS);
1316
+ const mapDur = hasOwnDur
1317
+ ? (map.duration ?? map.endAt ?? totalDwellBudget + totalDwell)
1318
+ : totalDwellBudget + totalDwell;
1319
+ const driveBudget = Math.max(0.1, mapDur - totalDwell);
1320
+ const scale = driveBudget / totalDrive;
1321
+
1322
+ const arrivalAt = new Map<string, number>();
1323
+ // The pin is at waypoint[0] from t=0 — `at` there means "from the start".
1324
+ const wp0 = waypoints[0]!;
1325
+ arrivalAt.set(wp0.label ?? String(wp0.lat) + "," + String(wp0.lng), 0);
1326
+ let cumDrive = 0;
1327
+ let cumDwells = 0;
1328
+ for (let i = 0; i < legs.length; i++) {
1329
+ cumDrive += legs[i]!.durationSec || 0;
1330
+ const wp = waypoints[i + 1]!;
1331
+ const arrival = (cumDrive * scale) + cumDwells;
1332
+ arrivalAt.set(wp.label ?? String(wp.lat) + "," + String(wp.lng), arrival);
1333
+ if (dwellAt.has(wp.label)) cumDwells += dwellAt.get(wp.label)!;
1334
+ }
1335
+
1336
+ // 4. Stamp children that lack explicit timing.
1337
+ const schedule: string[] = [];
1338
+ for (const c of anchored) {
1339
+ const arrival = arrivalAt.get(c.at);
1340
+ if (arrival == null) {
1341
+ console.warn(` ⚠ map child at:"${c.at}" — no waypoint with that label; renders full-screen`);
1342
+ continue;
1343
+ }
1344
+ if (typeof c.start === "number") {
1345
+ schedule.push(` 🛑 ${c.at}: manual ${c.start.toFixed(1)}s (unchanged)`);
1346
+ continue;
1347
+ }
1348
+ const dur = childDuration(c);
1349
+ c.start = arrival;
1350
+ c.end = arrival + dur;
1351
+ schedule.push(` 🛑 ${c.at}: ${arrival.toFixed(1)}s → ${(arrival + dur).toFixed(1)}s (${dur.toFixed(1)}s)`);
1352
+ }
1353
+
1354
+ if (schedule.length) {
1355
+ console.log(` 🗺 route stops (${mapDur.toFixed(1)}s total):`);
1356
+ for (const line of schedule) console.log(line);
1357
+ }
1358
+
1359
+ // 5. Extend map duration if the author didn't set one (or it's too short).
1360
+ if (!hasOwnDur) {
1361
+ map.duration = mapDur;
1362
+ } else if ((map.duration ?? 0) < mapDur) {
1363
+ map.duration = mapDur;
1364
+ console.log(` 🗺 extended map duration → ${mapDur.toFixed(1)}s (drives + dwells)`);
1365
+ }
1366
+ }
1367
+