@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
|
@@ -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[] {
|
package/tests/render.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/tests/schema.test.ts
CHANGED
|
@@ -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
|
Binary file
|
|
Binary file
|
|
Binary file
|