@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
@@ -0,0 +1,36 @@
1
+ {
2
+ "root": {
3
+ "type": "root",
4
+ "width": 640,
5
+ "height": 480,
6
+ "fps": 30,
7
+ "layout": "series",
8
+ "children": [
9
+ {
10
+ "type": "map",
11
+ "id": "sv-walk",
12
+ "view": "streetview",
13
+ "start": 0,
14
+ "end": 6,
15
+ "duration": 6,
16
+ "streetView": {
17
+ "route": [
18
+ {
19
+ "lat": 37.7785,
20
+ "lng": -122.4185
21
+ },
22
+ {
23
+ "lat": 37.7777,
24
+ "lng": -122.4178
25
+ }
26
+ ],
27
+ "radius": 50,
28
+ "pov": {
29
+ "heading": 15,
30
+ "pitch": -5
31
+ }
32
+ }
33
+ }
34
+ ]
35
+ }
36
+ }
@@ -897,6 +897,139 @@ describe("resolveDialogue", () => {
897
897
  });
898
898
  });
899
899
 
900
+ // ── Map Dynamic Views ─────────────────────────────────────────────────────
901
+
902
+ describe("map dynamic views (tween camera)", () => {
903
+ const md = `
904
+ # video
905
+ width:640 height:480 fps:30 layout:series
906
+
907
+ ## Overview
908
+ layout:parallel
909
+ - script "This is the city."
910
+ - map view:overview mapType:satellite duration:4 camera:{zoom:tween(6, 12, easeInOut)}
911
+
912
+ ## Cinematic
913
+ layout:parallel
914
+ - script "Flying over."
915
+ - map view:cinematic duration:8 cinematic:{mode:flyAlong, tilt:tween(0,45)} waypoints:[37.77,-122.41,"SF","photo1.jpg"; 34.05,-118.25,"LA","photo2.jpg"]
916
+
917
+ ## Streetview
918
+ layout:parallel
919
+ - script "On the ground."
920
+ - map view:streetview duration:6 streetView:{location:{lat:37.77,lng:-122.41}, pov:{heading:tween(200,320)}, zoom:tween(0,1)}
921
+ `;
922
+
923
+ it("parses view/camera/cinematic/streetView into descriptive nodes", () => {
924
+ const parsed = parseMarkdownDescriptive(md);
925
+ const scenes = parsed.children as any[];
926
+ const overview = scenes[0].children.find((c: any) => c.type === "map");
927
+ expect(overview.view).toBe("overview");
928
+ expect(overview.camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
929
+
930
+ const cinematic = scenes[1].children.find((c: any) => c.type === "map");
931
+ expect(cinematic.view).toBe("cinematic");
932
+ expect(cinematic.cinematic?.mode).toBe("flyAlong");
933
+ expect(cinematic.cinematic?.tilt).toEqual({ __tween: [0, 45] });
934
+ expect(cinematic.waypoints[0].media).toBe("photo1.jpg");
935
+ expect(cinematic.waypoints[1].media).toBe("photo2.jpg");
936
+
937
+ const street = scenes[2].children.find((c: any) => c.type === "map");
938
+ expect(street.view).toBe("streetview");
939
+ expect(street.streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
940
+ expect(street.streetView?.zoom).toEqual({ __tween: [0, 1] });
941
+ });
942
+
943
+ it("compiles view fields into the stream tree", () => {
944
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
945
+ const maps = (compiled.children as any[]).flatMap((c: any) =>
946
+ (c.children ?? []).filter((ch: any) => ch.type === "map"),
947
+ );
948
+ expect(maps).toHaveLength(3);
949
+ expect(maps[0].view).toBe("overview");
950
+ expect(maps[0].camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
951
+ expect(maps[1].view).toBe("cinematic");
952
+ expect(maps[1].cinematic?.tilt).toEqual({ __tween: [0, 45] });
953
+ expect(maps[2].view).toBe("streetview");
954
+ expect(maps[2].streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
955
+ });
956
+ });
957
+
958
+ // ── Map Multi-Leg Travel Modes ─────────────────────────────────────────────
959
+
960
+ describe("map multi-leg travel modes (per-waypoint)", () => {
961
+ const md = `
962
+ # video
963
+ width:640 height:480 fps:30 layout:series
964
+
965
+ ## Trip
966
+ layout:parallel
967
+ - map view:route duration:12 travelMode:DRIVING
968
+ waypoints:[37.8199,-122.4783,"SFO",FLIGHT; 33.94,-118.41,"LAX",BOAT; 33.75,-118.28,"Long Beach",WALKING; 33.77,-118.19,"Promenade",DRIVING; 33.94,-118.41,"LAX"]
969
+ `;
970
+
971
+ it("parses a per-waypoint travel mode as the 5th field", () => {
972
+ const parsed = parseMarkdownDescriptive(md);
973
+ const map = (parsed.children as any[])[0].children.find((c: any) => c.type === "map");
974
+ expect(map.waypoints.map((w: any) => w.mode)).toEqual([
975
+ "FLIGHT", "BOAT", "WALKING", "DRIVING", undefined,
976
+ ]);
977
+ expect(map.waypoints[0].media).toBeUndefined();
978
+ expect(map.waypoints[0].label).toBe("SFO");
979
+ });
980
+
981
+ it("compiles waypoint modes into the stream tree", () => {
982
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
983
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
984
+ expect(map.waypoints.map((w: any) => w.mode)).toEqual([
985
+ "FLIGHT", "BOAT", "WALKING", "DRIVING", undefined,
986
+ ]);
987
+ });
988
+ });
989
+
990
+ // ── Map Overlay Children (at:"Waypoint") ──────────────────────────────────
991
+
992
+ describe("map overlay children (at:\"Waypoint\")", () => {
993
+ const md = `
994
+ # video
995
+ width:640 height:480 fps:30 layout:series
996
+
997
+ ## Tour
998
+ layout:parallel
999
+ - map view:route duration:12 travelMode:DRIVING
1000
+ waypoints:[37.81,-122.48,"Golden Gate"; 37.77,-122.42,"Civic Center"]
1001
+ - image src:gg.jpg at:"Golden Gate" start:3 duration:3 effects:[zoomIn]
1002
+ - image src:civic.jpg at:"Civic Center" start:7 duration:3 effects:[zoomIn]
1003
+ `;
1004
+
1005
+ it("parses nested children with at onto the map node", () => {
1006
+ const parsed = parseMarkdownDescriptive(md);
1007
+ const map = (parsed.children as any[])[0].children.find((c: any) => c.type === "map");
1008
+ expect(map.children).toHaveLength(2);
1009
+ expect(map.children[0].type).toBe("image");
1010
+ expect(map.children[0].at).toBe("Golden Gate");
1011
+ expect(map.children[1].at).toBe("Civic Center");
1012
+ });
1013
+
1014
+ it("compiles children into the map stream and propagates at through effects", () => {
1015
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
1016
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
1017
+ expect(map.children).toHaveLength(2);
1018
+ // effects:[zoomIn] wraps each child in an effect node that carries `at`
1019
+ expect(map.children[0].type).toBe("effect");
1020
+ expect(map.children[0].at).toBe("Golden Gate");
1021
+ expect(map.children[0].start).toBe(3);
1022
+ expect(map.children[1].at).toBe("Civic Center");
1023
+ });
1024
+
1025
+ it("keeps the map duration from its own end, not max(children)", () => {
1026
+ const compiled = compileDescriptiveRoot(parseMarkdownDescriptive(md));
1027
+ const map = (compiled.children as any[])[0].children.find((c: any) => c.type === "map");
1028
+ expect(map.durationInSeconds).toBe(12);
1029
+ expect(map.end).toBe(12);
1030
+ });
1031
+ });
1032
+
900
1033
  // ── Helper ────────────────────────────────────────────────────────────────
901
1034
 
902
1035
  function findComponents(node: any): any[] {
@@ -21,6 +21,7 @@ import {
21
21
  FIXTURES_DIR,
22
22
  } from "./utils";
23
23
  import { existsSync, rmSync, mkdirSync } from "node:fs";
24
+ import { execSync } from "node:child_process";
24
25
  import { resolve } from "node:path";
25
26
 
26
27
  // Increase timeout for integration tests (rendering + STT can take a while)
@@ -258,6 +259,97 @@ describe("Map Rendering", () => {
258
259
  // Check file size as proxy for visual content.
259
260
  expect(getFrameFileSize(frame)).toBeGreaterThan(1000);
260
261
  });
262
+
263
+ it("renders anchored overlay children at waypoints (dwell vs drive)", async () => {
264
+ const output = renderFixture(fixturePath("map-overlay.json"), {
265
+ outputName: "map-overlay.mp4",
266
+ timeout: RENDER_TIMEOUT,
267
+ });
268
+
269
+ const info = getVideoInfo(output);
270
+ expect(info.width).toBe(640);
271
+
272
+ // The map has a solid-magenta photo anchored at "Golden Gate" (0–4s,
273
+ // zoomIn), then the pin drives to SFO. The photo must be visible during
274
+ // the dwell and gone during the drive — guards regressions where anchored
275
+ // overlays silently never render (getProjection() null / lazy effect path).
276
+ const dwell = outPath("frames/map-overlay-dwell.png");
277
+ const drive = outPath("frames/map-overlay-drive.png");
278
+ extractFrame(output, 2.5, dwell);
279
+ extractFrame(output, 6, drive);
280
+
281
+ const magentaFraction = (f: string): number => {
282
+ const p = execSync(
283
+ `ffmpeg -loglevel error -i "${f}" -vf scale=160:120 -f rawvideo -pix_fmt rgb24 pipe:1`,
284
+ );
285
+ let count = 0;
286
+ for (let i = 0; i < p.length; i += 3) {
287
+ if (p[i]! > 200 && p[i + 1]! < 80 && p[i + 2]! > 200) count++;
288
+ }
289
+ return count / (p.length / 3);
290
+ };
291
+
292
+ expect(magentaFraction(dwell)).toBeGreaterThan(0.02); // photo visible during dwell
293
+ expect(magentaFraction(drive)).toBeLessThan(0.001); // gone during drive
294
+ });
295
+
296
+ it("renders street view panoramas non-dark", async () => {
297
+ const output = renderFixture(fixturePath("map-dynamic.json"), {
298
+ outputName: "map-dynamic-streetview.mp4",
299
+ timeout: RENDER_TIMEOUT,
300
+ });
301
+
302
+ // map-dynamic.json is a series: dolly 0–3s, cinematic 3–11s, then the
303
+ // static streetview-pan scene plays ~11–17s. Its panorama must not be
304
+ // black — guards regressions where static Street View captures dark frames
305
+ // (pano_changed race + too-short fallback).
306
+ const frame = outPath("frames/map-dynamic-streetview.png");
307
+ extractFrame(output, 13, frame);
308
+
309
+ const darkFraction = (f: string): number => {
310
+ const p = execSync(
311
+ `ffmpeg -loglevel error -i "${f}" -vf scale=120:90 -f rawvideo -pix_fmt rgb24 pipe:1`,
312
+ );
313
+ let dark = 0;
314
+ for (let i = 0; i < p.length; i += 3) {
315
+ const v = (p[i]! + p[i + 1]! + p[i + 2]!) / 3;
316
+ if (v < 20) dark++;
317
+ }
318
+ return dark / (p.length / 3);
319
+ };
320
+
321
+ expect(darkFraction(frame)).toBeLessThan(0.5);
322
+ });
323
+
324
+ it("renders street view walk (route) non-dark at both waypoints", async () => {
325
+ const output = renderFixture(fixturePath("streetview-walk.json"), {
326
+ outputName: "streetview-walk.mp4",
327
+ timeout: RENDER_TIMEOUT,
328
+ });
329
+
330
+ // The snap-walk holds at route[0] (101 Grove St) for the first half and
331
+ // route[1] (95 Hayes St) for the second. Both have imagery; neither frame
332
+ // should be black. Guards regressions where the walk's per-frame pano
333
+ // requests get rate-limited (429) and render dark frames.
334
+ const darkFraction = (f: string): number => {
335
+ const p = execSync(
336
+ `ffmpeg -loglevel error -i "${f}" -vf scale=120:90 -f rawvideo -pix_fmt rgb24 pipe:1`,
337
+ );
338
+ let dark = 0;
339
+ for (let i = 0; i < p.length; i += 3) {
340
+ const v = (p[i]! + p[i + 1]! + p[i + 2]!) / 3;
341
+ if (v < 20) dark++;
342
+ }
343
+ return dark / (p.length / 3);
344
+ };
345
+
346
+ const wp0 = outPath("frames/streetview-walk-wp0.png");
347
+ const wp1 = outPath("frames/streetview-walk-wp1.png");
348
+ extractFrame(output, 2, wp0);
349
+ extractFrame(output, 4.5, wp1);
350
+ expect(darkFraction(wp0)).toBeLessThan(0.5);
351
+ expect(darkFraction(wp1)).toBeLessThan(0.5);
352
+ });
261
353
  });
262
354
 
263
355
  // ───────────────────────────────────────────────────────────────────────────
@@ -385,6 +477,7 @@ describe("Full Feature Combination", () => {
385
477
  "effects.json",
386
478
  "subtitle.json",
387
479
  "map.json",
480
+ "map-dynamic.json",
388
481
  "audio.json",
389
482
  "subvideo.json",
390
483
  "full.json",
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ haversineKm,
4
+ greatCirclePath,
5
+ makeSyntheticLeg,
6
+ modeEmoji,
7
+ routePositionAtLegs,
8
+ isSyntheticMode,
9
+ type RouteLeg,
10
+ } from "../src/utils/route-legs";
11
+
12
+ const SF = { lat: 37.7749, lng: -122.4194 };
13
+ const LA = { lat: 34.0522, lng: -118.2437 };
14
+ const PIER = { lat: 34.0094, lng: -118.4969 };
15
+
16
+ describe("haversineKm", () => {
17
+ it("is ~0 for identical points", () => {
18
+ expect(haversineKm(SF, SF)).toBeCloseTo(0, 6);
19
+ });
20
+
21
+ it("is ~560 km for SF→LA (great-circle)", () => {
22
+ expect(haversineKm(SF, LA)).toBeGreaterThan(500);
23
+ expect(haversineKm(SF, LA)).toBeLessThan(620);
24
+ });
25
+ });
26
+
27
+ describe("greatCirclePath", () => {
28
+ it("includes both endpoints and is monotonic in length", () => {
29
+ const path = greatCirclePath(SF, LA, 32);
30
+ expect(path.length).toBe(33);
31
+ expect(path[0]).toEqual(SF);
32
+ const last = path[path.length - 1]!;
33
+ expect(last.lat).toBeCloseTo(LA.lat, 4);
34
+ expect(last.lng).toBeCloseTo(LA.lng, 4);
35
+ });
36
+
37
+ it("handles identical endpoints without NaN", () => {
38
+ const path = greatCirclePath(SF, SF, 8);
39
+ expect(path.every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true);
40
+ expect(path[0]).toEqual(SF);
41
+ });
42
+ });
43
+
44
+ describe("makeSyntheticLeg", () => {
45
+ it("builds a FLIGHT leg with a cruise-speed duration and single arc step", () => {
46
+ const leg = makeSyntheticLeg(SF, LA, "FLIGHT");
47
+ expect(leg.mode).toBe("FLIGHT");
48
+ expect(leg.durationSec).toBeGreaterThan(0);
49
+ // SF→LA ~559 km @ 850 km/h ≈ 0.66 h ≈ 2370 s
50
+ expect(leg.durationSec).toBeGreaterThan(2000);
51
+ expect(leg.durationSec).toBeLessThan(2700);
52
+ expect(leg.steps).toHaveLength(1);
53
+ expect(leg.steps[0]!.path.length).toBeGreaterThan(10);
54
+ });
55
+
56
+ it("builds a BOAT leg with a slower speed (longer duration for same distance)", () => {
57
+ const boat = makeSyntheticLeg(SF, LA, "BOAT");
58
+ const flight = makeSyntheticLeg(SF, LA, "FLIGHT");
59
+ expect(boat.mode).toBe("BOAT");
60
+ expect(boat.durationSec).toBeGreaterThan(flight.durationSec);
61
+ });
62
+ });
63
+
64
+ describe("isSyntheticMode / modeEmoji", () => {
65
+ it("flags FLIGHT and BOAT as synthetic", () => {
66
+ expect(isSyntheticMode("FLIGHT")).toBe(true);
67
+ expect(isSyntheticMode("BOAT")).toBe(true);
68
+ expect(isSyntheticMode("flight")).toBe(true);
69
+ expect(isSyntheticMode("DRIVING")).toBe(false);
70
+ expect(isSyntheticMode("WALKING")).toBe(false);
71
+ expect(isSyntheticMode(undefined)).toBe(false);
72
+ });
73
+
74
+ it("maps modes to emoji", () => {
75
+ expect(modeEmoji("FLIGHT")).toBe("✈️");
76
+ expect(modeEmoji("BOAT")).toBe("🚢");
77
+ expect(modeEmoji("WALKING")).toBe("🚶");
78
+ expect(modeEmoji("BICYCLING")).toBe("🚲");
79
+ expect(modeEmoji("TRANSIT")).toBe("🚌");
80
+ expect(modeEmoji("DRIVING")).toBe("🚗");
81
+ expect(modeEmoji(undefined)).toBe("📍");
82
+ });
83
+ });
84
+
85
+ describe("routePositionAtLegs", () => {
86
+ const makeLeg = (mode: string, durationSec: number, path: { lat: number; lng: number }[]): RouteLeg => ({
87
+ mode,
88
+ from: path[0]!,
89
+ to: path[path.length - 1]!,
90
+ durationSec,
91
+ steps: [{ path, durationSec }],
92
+ });
93
+
94
+ const straight = (a: { lat: number; lng: number }, b: { lat: number; lng: number }, n = 20) => {
95
+ const out: { lat: number; lng: number }[] = [];
96
+ for (let i = 0; i <= n; i++) {
97
+ const t = i / n;
98
+ out.push({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
99
+ }
100
+ return out;
101
+ };
102
+
103
+ const legs: RouteLeg[] = [
104
+ makeLeg("FLIGHT", 10, straight(SF, LA)),
105
+ makeLeg("DRIVING", 30, straight(LA, PIER)),
106
+ ];
107
+
108
+ it("returns null for no legs", () => {
109
+ expect(routePositionAtLegs([], 40, 0)).toBeNull();
110
+ });
111
+
112
+ it("starts at the first waypoint and ends at the last", () => {
113
+ const start = routePositionAtLegs(legs, 40, 0)!;
114
+ expect(start.lat).toBeCloseTo(SF.lat, 5);
115
+ expect(start.lng).toBeCloseTo(SF.lng, 5);
116
+ expect(start.mode).toBe("FLIGHT");
117
+
118
+ const end = routePositionAtLegs(legs, 40, 40)!;
119
+ expect(end.lat).toBeCloseTo(PIER.lat, 5);
120
+ expect(end.lng).toBeCloseTo(PIER.lng, 5);
121
+ expect(end.mode).toBe("DRIVING");
122
+ });
123
+
124
+ it("splits time proportionally to leg duration and reports the current leg mode", () => {
125
+ // leg durations 10 + 30 = 40 total; actionDuration 40s
126
+ // → flight leg occupies t∈[0,10], driving leg t∈[10,40]
127
+ const atFlightEnd = routePositionAtLegs(legs, 40, 9.99)!;
128
+ expect(atFlightEnd.mode).toBe("FLIGHT");
129
+
130
+ const atDrivingStart = routePositionAtLegs(legs, 40, 10.01)!;
131
+ expect(atDrivingStart.mode).toBe("DRIVING");
132
+ expect(atDrivingStart.lat).toBeCloseTo(LA.lat, 4);
133
+ });
134
+
135
+ it("mid-leg position tracks the leg's path (proportional timing)", () => {
136
+ // leg durations [10,30]; actionDuration 40. At t=20 the driving leg is
137
+ // (20-10)/30 = 1/3 of the way from LA → PIER (with point quantization).
138
+ const atThird = 1 / 3;
139
+ const mid = routePositionAtLegs(legs, 40, 20)!;
140
+ expect(mid.mode).toBe("DRIVING");
141
+ expect(mid.lat).toBeCloseTo(LA.lat + (PIER.lat - LA.lat) * atThird, 2);
142
+ expect(mid.lng).toBeCloseTo(LA.lng + (PIER.lng - LA.lng) * atThird, 2);
143
+ });
144
+
145
+ it("clamps past-the-end seconds to the destination", () => {
146
+ const end = routePositionAtLegs(legs, 40, 100)!;
147
+ expect(end.lat).toBeCloseTo(PIER.lat, 5);
148
+ expect(end.mode).toBe("DRIVING");
149
+ });
150
+
151
+ it("holds at a waypoint during a dwell window", () => {
152
+ const stops = [{ label: "LA", at: LA, mode: "FLIGHT", fromSec: 8, toSec: 12 }];
153
+ // Inside the dwell → pin holds at LA, not on the flight arc.
154
+ const during = routePositionAtLegs(legs, 40, 10, stops)!;
155
+ expect(during.mode).toBe("FLIGHT");
156
+ expect(during.lat).toBeCloseTo(LA.lat, 4);
157
+ expect(during.lng).toBeCloseTo(LA.lng, 4);
158
+
159
+ // At toSec the dwell is over and motion resumes (no longer holding at LA).
160
+ const resumed = routePositionAtLegs(legs, 40, 12, stops)!;
161
+ expect(resumed.mode).toBe("FLIGHT");
162
+ expect(resumed.lat).toBeGreaterThan(LA.lat);
163
+ });
164
+
165
+ it("compresses drive time around dwells (dwell removed from drive budget)", () => {
166
+ const stops = [{ label: "LA", at: LA, mode: "FLIGHT", fromSec: 8, toSec: 12 }];
167
+ // With the 4s dwell at LA, at wall-clock t=12 the pin is only ~89% along
168
+ // the flight arc (still north of LA) because the drive budget shrank.
169
+ const withStop = routePositionAtLegs(legs, 40, 12, stops)!;
170
+ expect(withStop.mode).toBe("FLIGHT");
171
+ expect(withStop.lat).toBeGreaterThan(LA.lat);
172
+ expect(withStop.lat).toBeLessThan(SF.lat);
173
+
174
+ // Without the stop, t=12 is already 20% into the LA→PIER driving leg.
175
+ const noStop = routePositionAtLegs(legs, 40, 12)!;
176
+ expect(noStop.mode).toBe("DRIVING");
177
+ });
178
+ });
@@ -6,7 +6,7 @@
6
6
  * field that the player server sets after bundling frontmatter imports.
7
7
  */
8
8
  import { describe, it, expect } from "vitest";
9
- import { root, component } from "../src/schema/index";
9
+ import { root, component, mapStream } from "../src/schema/index";
10
10
 
11
11
  describe("root.imports — component registry passthrough", () => {
12
12
  it("preserves a string bundle URL through root.parse()", () => {
@@ -69,3 +69,78 @@ describe("component node", () => {
69
69
  ).toThrow();
70
70
  });
71
71
  });
72
+
73
+ describe("map stream — dynamic camera views", () => {
74
+ const base = { type: "map", waypoints: [{ lat: 37.77, lng: -122.41 }], start: 0, end: 5 };
75
+
76
+ it("defaults view to route", () => {
77
+ expect(mapStream.parse(base).view).toBe("route");
78
+ });
79
+
80
+ it("accepts all views", () => {
81
+ for (const view of ["overview", "route", "cinematic", "streetview"] as const) {
82
+ expect(mapStream.parse({ ...base, view }).view).toBe(view);
83
+ }
84
+ });
85
+
86
+ it("carries camera tween specs and static numbers", () => {
87
+ const m = mapStream.parse({
88
+ ...base,
89
+ view: "overview",
90
+ camera: { zoom: { __tween: [6, 12, "easeInOut"] }, tilt: 45 },
91
+ });
92
+ expect(m.camera?.zoom).toEqual({ __tween: [6, 12, "easeInOut"] });
93
+ expect(m.camera?.tilt).toBe(45);
94
+ });
95
+
96
+ it("carries cinematic config with tweenable tilt/range", () => {
97
+ const m = mapStream.parse({
98
+ ...base,
99
+ view: "cinematic",
100
+ cinematic: { mode: "flyTo", tilt: { __tween: [0, 45] }, range: 2000, fallback: "2d" },
101
+ });
102
+ expect(m.cinematic?.mode).toBe("flyTo");
103
+ expect(m.cinematic?.tilt).toEqual({ __tween: [0, 45] });
104
+ expect(m.cinematic?.range).toBe(2000);
105
+ expect(m.cinematic?.fallback).toBe("2d");
106
+ });
107
+
108
+ it("carries streetView config with pov tweens, zoom and walk route", () => {
109
+ const m = mapStream.parse({
110
+ ...base,
111
+ view: "streetview",
112
+ streetView: {
113
+ location: { lat: 37.77, lng: -122.41 },
114
+ pov: { heading: { __tween: [200, 320] }, pitch: -10 },
115
+ zoom: 0.5,
116
+ route: [{ lat: 37.77, lng: -122.41 }, { lat: 37.78, lng: -122.42 }],
117
+ },
118
+ });
119
+ expect(m.streetView?.pov?.heading).toEqual({ __tween: [200, 320] });
120
+ expect(m.streetView?.pov?.pitch).toBe(-10);
121
+ expect(m.streetView?.zoom).toBe(0.5);
122
+ expect(m.streetView?.route?.length).toBe(2);
123
+ });
124
+
125
+ it("rejects an invalid view", () => {
126
+ expect(() => mapStream.parse({ ...base, view: "drone" })).toThrow();
127
+ });
128
+
129
+ it("carries per-waypoint travel modes (FLIGHT/BOAT/DRIVING...)", () => {
130
+ const m = mapStream.parse({
131
+ ...base,
132
+ waypoints: [
133
+ { lat: 37.77, lng: -122.41, mode: "FLIGHT" },
134
+ { lat: 34.05, lng: -118.25, mode: "BOAT" },
135
+ { lat: 34.01, lng: -118.5, mode: "WALKING" },
136
+ ],
137
+ });
138
+ expect(m.waypoints.map((w) => w.mode)).toEqual(["FLIGHT", "BOAT", "WALKING"]);
139
+ });
140
+
141
+ it("rejects an invalid waypoint travel mode", () => {
142
+ expect(() =>
143
+ mapStream.parse({ ...base, waypoints: [{ lat: 37.77, lng: -122.41, mode: "TELEPORT" }] }),
144
+ ).toThrow();
145
+ });
146
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ classifyAssetPath,
4
+ collectTreeAssets,
5
+ validateAssetsRelative,
6
+ } from "../src/render/validate-assets.mjs";
7
+
8
+ const BASE = "/proj/md";
9
+
10
+ describe("classifyAssetPath", () => {
11
+ it("allows remote URIs", () => {
12
+ expect(classifyAssetPath("https://x/y.png")).toBe("remote");
13
+ expect(classifyAssetPath("http://x/y.mp4")).toBe("remote");
14
+ expect(classifyAssetPath("data:image/png;base64,AAA")).toBe("remote");
15
+ expect(classifyAssetPath("blob:https://x/id")).toBe("remote");
16
+ expect(classifyAssetPath("file:///tmp/a.png")).toBe("remote");
17
+ });
18
+
19
+ it("flags root-absolute and absolute paths", () => {
20
+ expect(classifyAssetPath("/Users/me/a.png")).toBe("root-absolute");
21
+ expect(classifyAssetPath("/a/b.png")).toBe("root-absolute");
22
+ });
23
+
24
+ it("flags .. escapes", () => {
25
+ expect(classifyAssetPath("../x.png")).toBe("escapes");
26
+ expect(classifyAssetPath("../../x.png")).toBe("escapes");
27
+ });
28
+
29
+ it("accepts source-folder-relative paths", () => {
30
+ expect(classifyAssetPath("assets/x.png")).toBe("relative");
31
+ expect(classifyAssetPath(".markcut/generated/tts/a.mp3")).toBe("relative");
32
+ expect(classifyAssetPath("a/b/c.png")).toBe("relative");
33
+ });
34
+
35
+ it("treats non-.vtt subtitle src as inline text (not a path)", () => {
36
+ expect(classifyAssetPath("Hello world", { subtitle: true })).toBe("text");
37
+ expect(classifyAssetPath("00:00:00.000 --> 00:00:02.000\nhi", { subtitle: true })).toBe("text");
38
+ expect(classifyAssetPath("subs.vtt", { subtitle: true })).toBe("relative");
39
+ expect(classifyAssetPath("subs.vtt?lang=en", { subtitle: true })).toBe("relative");
40
+ });
41
+ });
42
+
43
+ describe("validateAssetsRelative", () => {
44
+ const tree = {
45
+ type: "root",
46
+ subtitle: { src: ".markcut/sub/subtitles.vtt" },
47
+ children: [
48
+ { id: "bg", type: "image", src: "assets/bg.png" },
49
+ { id: "clip", type: "video", src: "/Users/me/abs.mp4" },
50
+ { id: "walk", type: "audio", src: "a/../../escape.mp3" },
51
+ { id: "m", type: "map", waypoints: [{ media: "../outside.png" }] },
52
+ { id: "sub", type: "subtitle", src: "caption text" },
53
+ { id: "inc", type: "include", src: "../other.md" },
54
+ ],
55
+ };
56
+
57
+ it("returns no errors when all assets are baseDir-relative", () => {
58
+ const ok = {
59
+ type: "root",
60
+ subtitle: { src: ".markcut/sub/subtitles.vtt" },
61
+ children: [
62
+ { id: "bg", type: "image", src: "assets/bg.png" },
63
+ { id: "clip", type: "video", src: ".markcut/generated/media/photo_1920x1080.jpg" },
64
+ { id: "a", type: "audio", src: "assets/tts/1.mp3" },
65
+ { id: "m", type: "map", waypoints: [{ media: "assets/thumb.png" }] },
66
+ ],
67
+ };
68
+ expect(validateAssetsRelative(ok, BASE)).toEqual([]);
69
+ });
70
+
71
+ it("flags absolute, root-absolute and escaping assets with node id + field", () => {
72
+ const errors = validateAssetsRelative(tree, BASE);
73
+ // clip (root-absolute), walk (nested ..), m.waypoints[0].media (..),
74
+ // inc.src (..) — subtitle text + bg are fine.
75
+ expect(errors).toHaveLength(4);
76
+ const joined = errors.join("\n");
77
+ expect(joined).toContain('node "clip" (type: video) — field src = "/Users/me/abs.mp4"');
78
+ expect(joined).toContain('node "walk" (type: audio)');
79
+ expect(joined).toContain("waypoints[0].media");
80
+ expect(joined).toContain('node "inc" (type: include)');
81
+ expect(joined).toContain("relative to the source folder");
82
+ });
83
+
84
+ it("skips include.src for descriptive trees (skipIncludeSrc)", () => {
85
+ const errors = validateAssetsRelative(tree, BASE, { skipIncludeSrc: true });
86
+ expect(errors).toHaveLength(3);
87
+ expect(errors.join("\n")).not.toContain('node "inc"');
88
+ });
89
+
90
+ it("collects root.subtitle.src as an asset reference", () => {
91
+ const refs = collectTreeAssets(tree);
92
+ expect(refs.some((r) => r.field === "subtitle.src" && r.value === ".markcut/sub/subtitles.vtt")).toBe(true);
93
+ });
94
+
95
+ it("flags a bad root subtitle path", () => {
96
+ const bad = { type: "root", subtitle: { src: "../shared/subs.vtt" }, children: [] };
97
+ const errors = validateAssetsRelative(bad, BASE);
98
+ expect(errors).toHaveLength(1);
99
+ expect(errors[0]).toContain("subtitle.src");
100
+ });
101
+
102
+ it("is robust to empty trees", () => {
103
+ expect(validateAssetsRelative({ type: "root" }, BASE)).toEqual([]);
104
+ expect(validateAssetsRelative(null, BASE)).toEqual([]);
105
+ });
106
+ });
package/B] DELETED
@@ -1,2 +0,0 @@
1
- [main 2b839d7] mermaid.animateEdges=true|[A-
2
- 5 files changed, 162 insertions(+), 18 deletions(-)
@@ -1,9 +0,0 @@
1
- {
2
- "test-clip": {
3
- "width": 320,
4
- "height": 240,
5
- "created": "0000-00-00T00:00:00Z",
6
- "location": null,
7
- "duration": 3
8
- }
9
- }