@lalalic/markcut 3.1.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/docs/map-dynamic-camera.md +92 -8
- package/src/descriptive/compiler.ts +60 -1
- package/src/descriptive/dsl.ts +27 -7
- package/src/descriptive/markdown.ts +2 -1
- package/src/descriptive/resolve.test.ts +98 -0
- package/src/descriptive/resolve.ts +156 -12
- package/src/player/bundle/player.js +222961 -222169
- package/src/player/pipeline.mjs +255 -17
- package/src/schema/index.ts +4 -0
- package/src/types/Effect.tsx +12 -1
- package/src/types/Map.tsx +649 -75
- package/src/utils/directions.ts +101 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/route-legs.ts +199 -0
- package/tests/dsl.test.ts +35 -0
- package/tests/fixtures/map-overlay.json +56 -0
- package/tests/fixtures/md/map-all-views.md +8 -1
- 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 +75 -0
- package/tests/render.test.ts +92 -0
- package/tests/route-legs.test.ts +178 -0
- package/tests/schema.test.ts +18 -0
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
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -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
|
@@ -125,4 +125,22 @@ describe("map stream — dynamic camera views", () => {
|
|
|
125
125
|
it("rejects an invalid view", () => {
|
|
126
126
|
expect(() => mapStream.parse({ ...base, view: "drone" })).toThrow();
|
|
127
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
|
+
});
|
|
128
146
|
});
|