@lalalic/markcut 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/map-dynamic-camera.md +328 -0
- package/skills/markcut/docs/markdown-descriptive.md +2 -0
- package/src/descriptive/compiler.ts +101 -1
- package/src/descriptive/dsl.ts +64 -7
- package/src/descriptive/markdown.ts +7 -1
- package/src/descriptive/resolve.test.ts +103 -5
- package/src/descriptive/resolve.ts +207 -24
- package/src/player/bundle/player.js +223296 -222155
- package/src/player/pipeline.mjs +314 -25
- package/src/player/pipeline.ts +5 -4
- package/src/player/server.mjs +22 -42
- package/src/render/cli.mjs +54 -3
- package/src/render/validate-assets.mjs +140 -0
- package/src/schema/index.ts +60 -1
- package/src/spots/cli.mjs +266 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +1078 -130
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +78 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/animate-diagrams.md +9 -7
- package/tests/fixtures/md/map-all-views.md +35 -0
- package/tests/fixtures/md/map-children.md +11 -0
- package/tests/fixtures/md/map-multimode.md +9 -0
- package/tests/fixtures/streetview-walk.json +36 -0
- package/tests/md-descriptive.test.ts +133 -0
- package/tests/render.test.ts +93 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +76 -1
- package/tests/validate-assets.test.ts +106 -0
- package/B] +0 -2
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/segments/test-clip_0to3_seg_1100to3000.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_0to3.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/.normalized/test-clip_audio.mp3 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/metadata.json +0 -9
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.mp4 +0 -0
- package/tests/tmp/vision-1785081637127-video/videos/test-clip.vtt +0 -5
|
@@ -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
|
-
/**
|
|
50
|
-
|
|
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
|
|
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
|
|
74
|
+
function resolveSrc(src: string, baseDir: string): string {
|
|
74
75
|
if (/^(https?:|file:|\/)/.test(src)) return src;
|
|
75
|
-
return resolvePath(baseDir
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
380
|
-
let ttsCli =
|
|
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 (
|
|
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
|
|
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
|
+
|