@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,101 @@
1
+ /**
2
+ * Server-side Google Directions REST helper.
3
+ *
4
+ * Used at resolve time (Node) to compute per-leg travel durations so map
5
+ * overlay children (`at:"Label"`) can be auto-timed (arrival + dwell). This
6
+ * is the same Directions REST pattern the spots CLI uses — one request per
7
+ * consecutive waypoint pair so mixed travel modes (FLIGHT/BOAT synthetic)
8
+ * can be interspersed with road legs.
9
+ *
10
+ * Requires GOOGLE_MAPS_API_KEY (Directions API enabled).
11
+ */
12
+ import { makeSyntheticLeg, haversineKm } from "./route-legs";
13
+
14
+ export interface RouteLegTiming {
15
+ mode: string;
16
+ from: { lat: number; lng: number };
17
+ to: { lat: number; lng: number };
18
+ /** Estimated travel time for this leg, in seconds. */
19
+ durationSec: number;
20
+ }
21
+
22
+ function apiKey(): string {
23
+ return (
24
+ (typeof process !== "undefined" && process.env.GOOGLE_MAPS_API_KEY) || ""
25
+ );
26
+ }
27
+
28
+ /** Directions travel modes (non-synthetic). */
29
+ const ROAD_MODES = new Set(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]);
30
+
31
+ /**
32
+ * Compute the travel time (seconds) for one leg. Road modes hit the
33
+ * Directions REST API; synthetic modes (FLIGHT/BOAT) are estimated from
34
+ * haversine distance at cruise speed — no API call, deterministic.
35
+ *
36
+ * Returns null when the Directions call fails (caller falls back to a
37
+ * straight-line estimate so timing still works offline).
38
+ */
39
+ export async function legDurationSec(
40
+ from: { lat: number; lng: number },
41
+ to: { lat: number; lng: number },
42
+ mode: string,
43
+ key = apiKey(),
44
+ ): Promise<number | null> {
45
+ const m = (mode || "").toUpperCase();
46
+ if (!ROAD_MODES.has(m)) {
47
+ // Synthetic: distance at cruise speed (same math as the renderer).
48
+ return makeSyntheticLeg(from, to, m).durationSec;
49
+ }
50
+ if (!key) {
51
+ // No key → straight-line estimate (consistent, deterministic).
52
+ return straightLineSec(from, to);
53
+ }
54
+
55
+ const url = new URL("https://maps.googleapis.com/maps/api/directions/json");
56
+ url.searchParams.set("origin", `${from.lat},${from.lng}`);
57
+ url.searchParams.set("destination", `${to.lat},${to.lng}`);
58
+ url.searchParams.set("mode", m.toLowerCase());
59
+ url.searchParams.set("key", key);
60
+
61
+ try {
62
+ const res = await fetch(url);
63
+ const data = await res.json();
64
+ const dur = data?.routes?.[0]?.legs?.[0]?.duration?.value;
65
+ if (typeof dur === "number" && dur > 0) return dur;
66
+ return straightLineSec(from, to);
67
+ } catch {
68
+ return straightLineSec(from, to);
69
+ }
70
+ }
71
+
72
+ /** Fallback straight-line estimate (~50 km/h), so timing works without the API. */
73
+ function straightLineSec(
74
+ from: { lat: number; lng: number },
75
+ to: { lat: number; lng: number },
76
+ ): number {
77
+ return (haversineKm(from, to) / 50) * 3600;
78
+ }
79
+
80
+ /**
81
+ * Compute per-leg timing for a full route.
82
+ *
83
+ * `modes[i]` is the travel mode of the leg leaving waypoints[i]
84
+ * (defaults to `defaultMode`). Returns one timing per consecutive pair.
85
+ */
86
+ export async function routeLegTimings(
87
+ waypoints: { lat: number; lng: number; mode?: string }[],
88
+ defaultMode = "DRIVING",
89
+ key = apiKey(),
90
+ ): Promise<RouteLegTiming[]> {
91
+ if (waypoints.length < 2) return [];
92
+ const out: RouteLegTiming[] = [];
93
+ for (let i = 0; i < waypoints.length - 1; i++) {
94
+ const from = waypoints[i]!;
95
+ const to = waypoints[i + 1]!;
96
+ const mode = (from.mode ?? defaultMode).toUpperCase();
97
+ const durationSec = (await legDurationSec(from, to, mode, key)) ?? 0;
98
+ out.push({ mode, from, to, durationSec });
99
+ }
100
+ return out;
101
+ }
@@ -104,6 +104,17 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
104
104
  return stream.durationInSeconds ?? 0;
105
105
  }
106
106
 
107
+ // Map is a leaf whose overlay children are positioned INSIDE it — its own
108
+ // base timing (end) defines the duration, not max(child durations).
109
+ if (stream.type === "map") {
110
+ const d = leafEnd(stream);
111
+ if (update) {
112
+ stream.durationInSeconds = d;
113
+ for (const child of stream.children ?? []) getDurationInSeconds(child, update);
114
+ }
115
+ return d;
116
+ }
117
+
107
118
  // include: if src is set, treat as leaf (duration from base end).
108
119
  // Otherwise fall back to inline children (legacy).
109
120
  if (stream.type === "include") {
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Multi-leg route math for the `map` stream (view:"route").
3
+ *
4
+ * A route is a sequence of legs — one per consecutive waypoint pair. Each
5
+ * waypoint may tag the leg that LEAVES it with its own travel mode
6
+ * (`waypoint.mode ?? map.travelMode`). Supported modes:
7
+ *
8
+ * DRIVING | WALKING | BICYCLING | TRANSIT → Google Directions API
9
+ * FLIGHT | BOAT → synthetic great-circle arc
10
+ * (Directions has no air/water routes; we draw a curved arc and time it
11
+ * from haversine distance at a cruise speed)
12
+ *
13
+ * Pure functions only (deterministic per second) so the animated marker and
14
+ * any future camera can share the same route-time math.
15
+ */
16
+ export interface RouteLegStep {
17
+ path: { lat: number; lng: number }[];
18
+ durationSec: number;
19
+ }
20
+
21
+ export interface RouteLeg {
22
+ mode: string;
23
+ from: { lat: number; lng: number };
24
+ to: { lat: number; lng: number };
25
+ durationSec: number;
26
+ steps: RouteLegStep[];
27
+ }
28
+
29
+ /** Cruise speeds used to estimate synthetic leg durations (km/h). */
30
+ export const FLIGHT_SPEED_KMH = 850;
31
+ export const BOAT_SPEED_KMH = 40;
32
+
33
+ /** Modes with no Directions route — rendered as synthetic great-circle arcs. */
34
+ export function isSyntheticMode(mode?: string): boolean {
35
+ const m = (mode ?? "").toUpperCase();
36
+ return m === "FLIGHT" || m === "BOAT";
37
+ }
38
+
39
+ const EARTH_RADIUS_KM = 6371;
40
+ const toRad = (d: number) => (d * Math.PI) / 180;
41
+ const toDeg = (d: number) => (d * 180) / Math.PI;
42
+
43
+ /** Great-circle (haversine) distance between two lat/lng points, in km. */
44
+ export function haversineKm(
45
+ a: { lat: number; lng: number },
46
+ b: { lat: number; lng: number },
47
+ ): number {
48
+ const dLat = toRad(b.lat - a.lat);
49
+ const dLng = toRad(b.lng - a.lng);
50
+ const h =
51
+ Math.sin(dLat / 2) ** 2 +
52
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
53
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h));
54
+ }
55
+
56
+ /**
57
+ * Interpolate the great-circle arc between two lat/lng points (the "as the
58
+ * crow flies" flight path), returning `points + 1` points including both ends.
59
+ */
60
+ export function greatCirclePath(
61
+ a: { lat: number; lng: number },
62
+ b: { lat: number; lng: number },
63
+ points = 64,
64
+ ): { lat: number; lng: number }[] {
65
+ const φ1 = toRad(a.lat);
66
+ const λ1 = toRad(a.lng);
67
+ const φ2 = toRad(b.lat);
68
+ const λ2 = toRad(b.lng);
69
+ const d = haversineKm(a, b) / EARTH_RADIUS_KM; // angular distance (radians)
70
+ const out: { lat: number; lng: number }[] = [];
71
+ for (let i = 0; i <= points; i++) {
72
+ if (d === 0) {
73
+ out.push({ lat: a.lat, lng: a.lng });
74
+ continue;
75
+ }
76
+ const f = i / points;
77
+ const A = Math.sin((1 - f) * d) / Math.sin(d);
78
+ const B = Math.sin(f * d) / Math.sin(d);
79
+ const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
80
+ const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
81
+ const z = A * Math.sin(φ1) + B * Math.sin(φ2);
82
+ out.push({ lat: toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))), lng: toDeg(Math.atan2(y, x)) });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Build a synthetic leg (FLIGHT ✈️ / BOAT 🚢) between two waypoints. */
88
+ export function makeSyntheticLeg(
89
+ from: { lat: number; lng: number },
90
+ to: { lat: number; lng: number },
91
+ mode: string,
92
+ ): RouteLeg {
93
+ const speedKmh = (mode || "").toUpperCase() === "BOAT" ? BOAT_SPEED_KMH : FLIGHT_SPEED_KMH;
94
+ const durationSec = (haversineKm(from, to) / speedKmh) * 3600;
95
+ return {
96
+ mode: mode.toUpperCase(),
97
+ from,
98
+ to,
99
+ durationSec,
100
+ steps: [{ path: greatCirclePath(from, to), durationSec }],
101
+ };
102
+ }
103
+
104
+ /** Emoji glyph for a leg's travel mode (used for the traveling marker). */
105
+ export function modeEmoji(mode?: string): string {
106
+ switch ((mode ?? "").toUpperCase()) {
107
+ case "FLIGHT": return "✈️";
108
+ case "BOAT": return "🚢";
109
+ case "WALKING": return "🚶";
110
+ case "BICYCLING": return "🚲";
111
+ case "TRANSIT": return "🚌";
112
+ case "DRIVING": return "🚗";
113
+ default: return "📍";
114
+ }
115
+ }
116
+
117
+ /** Position within one leg at normalized progress t ∈ [0,1] (step-timed). */
118
+ export function positionAlongLeg(
119
+ leg: RouteLeg,
120
+ t: number,
121
+ ): { lat: number; lng: number } | null {
122
+ if (leg.steps.length === 0) return null;
123
+ const currentInSecond = t * leg.durationSec;
124
+ let acc = 0;
125
+ for (const step of leg.steps) {
126
+ if (currentInSecond <= acc + step.durationSec) {
127
+ const stepElapsed = currentInSecond - acc;
128
+ const stepProgress = step.durationSec > 0 ? stepElapsed / step.durationSec : 0;
129
+ const idx = Math.min(
130
+ Math.max(0, Math.floor(stepProgress * step.path.length)),
131
+ step.path.length - 1,
132
+ );
133
+ return step.path[idx] ?? null;
134
+ }
135
+ acc += step.durationSec;
136
+ }
137
+ const last = leg.steps[leg.steps.length - 1]!;
138
+ return last.path[last.path.length - 1] ?? null;
139
+ }
140
+
141
+ /**
142
+ * A dwell window where the pin holds still at a waypoint (a map overlay
143
+ * child is showing). `fromSec`/`toSec` are in the same timeline as
144
+ * `routePositionAtLegs`'s `seconds`.
145
+ */
146
+ export interface RouteStopWindow {
147
+ label: string;
148
+ at: { lat: number; lng: number };
149
+ mode: string;
150
+ fromSec: number;
151
+ toSec: number;
152
+ }
153
+
154
+ /**
155
+ * Position of the traveling marker at a given timeline second, plus the mode
156
+ * of the leg it is currently on. Time is split across legs proportionally to
157
+ * each leg's duration. When `stops` is provided, the marker holds still at a
158
+ * waypoint during its dwell window (drive time is compressed around the
159
+ * dwells, matching the resolver's arrival math). Returns null when there is
160
+ * no route.
161
+ */
162
+ export function routePositionAtLegs(
163
+ legs: RouteLeg[],
164
+ actionDuration: number,
165
+ seconds: number,
166
+ stops: RouteStopWindow[] = [],
167
+ ): { lat: number; lng: number; mode: string } | null {
168
+ if (legs.length === 0) return null;
169
+
170
+ // Inside a dwell window → hold at that waypoint.
171
+ const inStop = stops.find((s) => seconds >= s.fromSec && seconds < s.toSec);
172
+ if (inStop) {
173
+ return { lat: inStop.at.lat, lng: inStop.at.lng, mode: inStop.mode };
174
+ }
175
+
176
+ // Drive time = timeline seconds with completed/overlapping dwells removed.
177
+ let dwellBefore = 0;
178
+ for (const s of stops) {
179
+ if (seconds >= s.toSec) dwellBefore += s.toSec - s.fromSec;
180
+ else if (seconds > s.fromSec) dwellBefore += seconds - s.fromSec;
181
+ }
182
+ const driveTime = Math.max(0, seconds - dwellBefore);
183
+ const totalDwell = stops.reduce((s, st) => s + (st.toSec - st.fromSec), 0);
184
+ const driveBudget = Math.max(0.1, actionDuration - totalDwell);
185
+ const total = legs.reduce((s, l) => s + l.durationSec, 0);
186
+ const currentInSecond = driveTime * (total / driveBudget);
187
+ let acc = 0;
188
+ for (const leg of legs) {
189
+ if (currentInSecond <= acc + leg.durationSec) {
190
+ const t = leg.durationSec > 0 ? Math.min(Math.max((currentInSecond - acc) / leg.durationSec, 0), 1) : 0;
191
+ const pos = positionAlongLeg(leg, t);
192
+ return pos ? { ...pos, mode: leg.mode } : null;
193
+ }
194
+ acc += leg.durationSec;
195
+ }
196
+ const last = legs[legs.length - 1]!;
197
+ const pos = positionAlongLeg(last, 1);
198
+ return pos ? { ...pos, mode: last.mode } : null;
199
+ }
@@ -14,7 +14,7 @@ import * as React from "react";
14
14
  import { interpolate, useCurrentFrame, Easing } from "remotion";
15
15
 
16
16
  /** Built-in easing name → Remotion easing function. */
17
- const EASING_MAP: Record<string, ((t: number) => number) | undefined> = {
17
+ export const EASING_MAP: Record<string, ((t: number) => number) | undefined> = {
18
18
  linear: undefined,
19
19
  ease: Easing.ease,
20
20
  easeIn: Easing.in(Easing.ease),
@@ -116,3 +116,51 @@ export function useTweenBindings(action: { start?: number; end?: number }): Reco
116
116
 
117
117
  return { tween, interpolate };
118
118
  }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Tween spec resolution (used by stream leaf renderers, e.g. Map.tsx)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * A tween expression as parsed by the descriptive DSL: `tween(from, to, easing?)`
126
+ * becomes `{ __tween: [from, to, easing?] }` (see parseProps in dsl.ts).
127
+ */
128
+ export interface TweenSpec {
129
+ __tween: Array<number | string>;
130
+ }
131
+
132
+ /** A static number OR a tween spec. */
133
+ export type Tweenable = number | TweenSpec;
134
+
135
+ /**
136
+ * Resolve a `Tweenable` to a per-frame value — deterministic per frame.
137
+ *
138
+ * - A plain number is returned as-is (static).
139
+ * - A `{__tween:[from,to,easing?]}` spec is interpolated over the node's
140
+ * `[start, end]` (seconds) using the current frame.
141
+ * - Anything malformed falls back to `fallback`.
142
+ */
143
+ export function resolveTween(
144
+ frame: number,
145
+ fps: number,
146
+ spec: Tweenable | undefined,
147
+ start: number,
148
+ end: number,
149
+ fallback: number,
150
+ ): number {
151
+ if (typeof spec === "number") return spec;
152
+ const tween = spec?.__tween;
153
+ if (!tween || tween.length < 2) return fallback;
154
+ const from = Number(tween[0]);
155
+ const to = Number(tween[1]);
156
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return fallback;
157
+ const easingName = typeof tween[2] === "string" ? tween[2] : undefined;
158
+ const easingFn = easingName ? EASING_MAP[easingName] : undefined;
159
+ const startF = Math.max(0, Math.floor(start * fps));
160
+ const endF = Math.max(startF + 1, Math.floor(end * fps));
161
+ return interpolate(frame, [startF, endF], [from, to], {
162
+ extrapolateLeft: "clamp",
163
+ extrapolateRight: "clamp",
164
+ easing: easingFn,
165
+ });
166
+ }
package/tests/dsl.test.ts CHANGED
@@ -100,6 +100,54 @@ describe("dsl — parseWaypoints", () => {
100
100
  expect(parseWaypoints("[40.7,-74.0]")).toEqual([{ lat: 40.7, lng: -74.0, label: undefined }]);
101
101
  });
102
102
 
103
+ it("parses waypoint media as a 4th field", () => {
104
+ expect(parseWaypoints('[40.7,-74.0,"NYC","photo1.jpg"; 34.05,-118.25,"LA","clip.mp4"]')).toEqual([
105
+ { lat: 40.7, lng: -74.0, label: "NYC", media: "photo1.jpg" },
106
+ { lat: 34.05, lng: -118.25, label: "LA", media: "clip.mp4" },
107
+ ]);
108
+ });
109
+
110
+ it("parses waypoint media without a label", () => {
111
+ expect(parseWaypoints('[40.7,-74.0,"","img.jpg"]')).toEqual([
112
+ { lat: 40.7, lng: -74.0, label: undefined, media: "img.jpg" },
113
+ ]);
114
+ });
115
+
116
+ it("parses a bare travel mode without an empty media slot (smart)", () => {
117
+ expect(parseWaypoints('[40.7,-74.0,"NYC",FLIGHT; 34.05,-118.25,"LA","photo.jpg",BOAT]')).toEqual([
118
+ { lat: 40.7, lng: -74.0, label: "NYC", media: undefined, mode: "FLIGHT" },
119
+ { lat: 34.05, lng: -118.25, label: "LA", media: "photo.jpg", mode: "BOAT" },
120
+ ]);
121
+ });
122
+
123
+ it("parses a bare travel mode as the only trailing field", () => {
124
+ expect(parseWaypoints('[40.7,-74.0,FLIGHT]')).toEqual([
125
+ { lat: 40.7, lng: -74.0, label: undefined, media: undefined, mode: "FLIGHT" },
126
+ ]);
127
+ });
128
+
129
+ it("parses a bare mode even before the label", () => {
130
+ expect(parseWaypoints('[40.7,-74.0,BOAT,"Pier"]')).toEqual([
131
+ { lat: 40.7, lng: -74.0, label: "Pier", media: undefined, mode: "BOAT" },
132
+ ]);
133
+ });
134
+
135
+ it("uppercases a lowercase bare mode", () => {
136
+ expect(parseWaypoints('[40.7,-74.0,"NYC",flight]')[0]!.mode).toBe("FLIGHT");
137
+ });
138
+
139
+ it("still accepts empty quoted media slots (backward compat)", () => {
140
+ expect(parseWaypoints('[40.7,-74.0,"NYC","",BOAT]')).toEqual([
141
+ { lat: 40.7, lng: -74.0, label: "NYC", media: undefined, mode: "BOAT" },
142
+ ]);
143
+ });
144
+
145
+ it("treats a quoted mode word as a literal label/media (escape hatch)", () => {
146
+ expect(parseWaypoints('[40.7,-74.0,"NYC","FLIGHT"]')).toEqual([
147
+ { lat: 40.7, lng: -74.0, label: "NYC", media: "FLIGHT", mode: undefined },
148
+ ]);
149
+ });
150
+
103
151
  it("returns empty array for non-bracket input", () => {
104
152
  expect(parseWaypoints("not a list")).toEqual([]);
105
153
  });
@@ -133,6 +181,36 @@ describe("dsl — parseProps", () => {
133
181
  it("returns {} for unclosed object", () => {
134
182
  expect(parseProps("{a:1")).toEqual({});
135
183
  });
184
+
185
+ it("parses tween() expressions into tagged specs", () => {
186
+ expect(parseProps("{zoom:tween(6, 12)}")).toEqual({ zoom: { __tween: [6, 12] } });
187
+ });
188
+
189
+ it("parses tween() with easing", () => {
190
+ expect(parseProps("{zoom:tween(6, 12, easeInOut)}")).toEqual({
191
+ zoom: { __tween: [6, 12, "easeInOut"] },
192
+ });
193
+ });
194
+
195
+ it("parses tween() nested inside objects and arrays", () => {
196
+ expect(parseProps("{center:{lat:tween(37.0, 37.9), lng:120}, pov:{heading:tween(200, 320)}}")).toEqual({
197
+ center: { lat: { __tween: [37.0, 37.9] }, lng: 120 },
198
+ pov: { heading: { __tween: [200, 320] } },
199
+ });
200
+ });
201
+
202
+ it("parses tween() with quoted string values (colors)", () => {
203
+ expect(parseProps('{color:tween("#000000", "#FFFFFF", easeOut)}')).toEqual({
204
+ color: { __tween: ["#000000", "#FFFFFF", "easeOut"] },
205
+ });
206
+ });
207
+
208
+ it("keeps static numbers alongside tweens", () => {
209
+ expect(parseProps("{zoom:tween(6, 12), tilt:45}")).toEqual({
210
+ zoom: { __tween: [6, 12] },
211
+ tilt: 45,
212
+ });
213
+ });
136
214
  });
137
215
 
138
216
  describe("dsl — parseOnSpec", () => {
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "root",
3
+ "type": "root",
4
+ "width": 640,
5
+ "height": 480,
6
+ "fps": 30,
7
+ "isSeries": true,
8
+ "transition": "fade",
9
+ "transitionTime": 0.3,
10
+ "children": [
11
+ {
12
+ "id": "overview",
13
+ "type": "map",
14
+ "view": "overview",
15
+ "name": "satellite-dolly",
16
+ "mapType": "satellite",
17
+ "center": { "lat": 37.7749, "lng": -122.4194 },
18
+ "zoom": 6,
19
+ "camera": { "zoom": { "__tween": [6, 12, "easeInOut"] } },
20
+ "start": 0,
21
+ "end": 3
22
+ },
23
+ {
24
+ "id": "cinematic",
25
+ "type": "map",
26
+ "view": "cinematic",
27
+ "name": "cinematic-flyover",
28
+ "waypoints": [
29
+ { "lat": 37.7749, "lng": -122.4194, "label": "SF" },
30
+ { "lat": 34.0522, "lng": -118.2437, "label": "LA" }
31
+ ],
32
+ "cinematic": { "mode": "flyAlong", "headingFollow": true, "tilt": { "__tween": [20, 45] } },
33
+ "camera": { "zoom": { "__tween": [12, 14, "easeInOut"] } },
34
+ "start": 0,
35
+ "end": 8
36
+ },
37
+ {
38
+ "id": "streetview",
39
+ "type": "map",
40
+ "view": "streetview",
41
+ "name": "streetview-pan",
42
+ "streetView": {
43
+ "location": { "lat": 37.7749, "lng": -122.4194 },
44
+ "radius": 50,
45
+ "pov": { "heading": { "__tween": [200, 320, "easeInOut"] }, "pitch": 0 },
46
+ "zoom": { "__tween": [0, 0.5, "easeInOut"] }
47
+ },
48
+ "start": 0,
49
+ "end": 6
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,56 @@
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": "map-overlay-test",
12
+ "view": "route",
13
+ "travelMode": "DRIVING",
14
+ "mapType": "roadmap",
15
+ "routeColor": "#4285F4",
16
+ "routeWeight": 5,
17
+ "waypoints": [
18
+ {
19
+ "lat": 37.8199,
20
+ "lng": -122.4783,
21
+ "label": "Golden Gate"
22
+ },
23
+ {
24
+ "lat": 37.6213,
25
+ "lng": -122.379,
26
+ "label": "SFO"
27
+ }
28
+ ],
29
+ "start": 0,
30
+ "end": 12,
31
+ "duration": 12,
32
+ "children": [
33
+ {
34
+ "type": "effect",
35
+ "animation": "zoomIn",
36
+ "animationIterationCount": 1,
37
+ "start": 0,
38
+ "end": 4,
39
+ "at": "Golden Gate",
40
+ "children": [
41
+ {
42
+ "type": "image",
43
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAACXBIWXMAAAAAAAAAAQCEeRdzAAAAYUlEQVR4nO3PwQkAIBDAMAX3X/kcwkcQmgnaPWvWz44OeNWA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaA1oDWgNaAdgFCjAL8Lb/n7gAAAABJRU5ErkJggg==",
44
+ "fit": "contain",
45
+ "start": 0,
46
+ "end": 4,
47
+ "at": "Golden Gate"
48
+ }
49
+ ]
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "seed": 592821268
55
+ }
56
+ }
@@ -13,8 +13,8 @@ layout:parallel
13
13
  ~~~
14
14
 
15
15
  ## FlowChart
16
- layout:parallel
17
- - component id:flowChart duration:12
16
+ layout:series
17
+ - component id:flowChart isBackground:true
18
18
  ~~~jsx
19
19
  <div style={{position:'absolute',top:0,left:0,width:640,height:480,background:'#1a1a2e',padding:10,fontFamily:'monospace',boxSizing:'border-box',display:'flex',flexDirection:'column'}}>
20
20
  <p style={{color:'#00d4ff',fontSize:12,textAlign:'center',margin:'0 0 4px 0',flexShrink:0}}>Flow — {highlight}</p>
@@ -33,8 +33,10 @@ layout:parallel
33
33
  F --> G["Send Response"]
34
34
  classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px,color:#000
35
35
  ~~~
36
- highlight:"A"
37
- animateEdges:true
38
- - event duration:3 start:3 on:(start, flowChart.highlight="B";flowChart.animateEdges=["B->C"])
39
- - event duration:3 start:6 on:(start, flowChart.highlight="C")
40
- - event duration:3 start:9 on:(start, flowChart.highlight=["D","G"])
36
+ - script on:(start, flowChart.highlight="A")
37
+ ~~~script
38
+
39
+ ~~~
40
+ - script on:(start, flowChart.highlight="B")
41
+ - script on:(start, flowChart.highlight="C")
42
+ - script on:(start, flowChart.highlight=["D","G"])
@@ -0,0 +1,35 @@
1
+ # video
2
+ seed:2668727180
3
+ width:640 height:480 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Satellite-Dolly
6
+ layout:parallel
7
+ - script "We begin high above San Francisco, then dive into the city."
8
+ - map view:overview mapType:satellite duration:4 center:{lat:37.7749,lng:-122.4194} camera:{zoom:tween(6, 12, easeInOut)}
9
+
10
+ ## Route
11
+ layout:parallel
12
+ - script "The route winds from the Golden Gate to the airport, with photos at each stop."
13
+ - map view:route duration:6 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗" waypoints:[37.8199,-122.4783,"Golden Gate","https://picsum.photos/seed/gg-bridge/96/96"; 37.7749,-122.4194,"Civic Center","https://picsum.photos/seed/civic-center/96/96"; 37.6213,-122.3790,"SFO","https://picsum.photos/seed/sfo-airport/96/96"]
14
+
15
+ ## Stops
16
+ layout:parallel
17
+ - script "The pin pauses at each landmark while a photo grows out of it."
18
+ - map view:route duration:16 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5 routeMarker:"🚗" waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
19
+ - image src:https://picsum.photos/seed/gg-photo/200/200 at:"Golden Gate" duration:3 effects:[zoomIn]
20
+ - image src:https://picsum.photos/seed/civic-photo/200/200 at:"Civic Center" duration:3 effects:[zoomIn]
21
+
22
+ ## Cinematic
23
+ layout:parallel
24
+ - script "The camera tilts and chases the road like a drone."
25
+ - map view:cinematic duration:8 travelMode:DRIVING mapType:satellite routeMarker:"🚗" cinematic:{mode:flyAlong, headingFollow:true, tilt:tween(0, 45, easeInOut)} camera:{zoom:tween(12, 14, easeInOut)} waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
26
+
27
+ ## Street-View
28
+ layout:parallel
29
+ - script "And finally, we land on the street itself."
30
+ - map view:streetview duration:8 streetView:{location:{lat:37.7793,lng:-122.4193}, radius:50, pov:{heading:tween(200, 420, easeInOut), pitch:tween(0, -8)}, zoom:tween(0, 0.6, easeInOut)}
31
+
32
+ ## Street-View-Walk
33
+ layout:parallel
34
+ - script "A quick walk down the block."
35
+ - map view:streetview duration:6 streetView:{route:[{lat:37.7785,lng:-122.4185}, {lat:37.7777,lng:-122.4178}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
@@ -0,0 +1,11 @@
1
+ # video
2
+ seed:42
3
+ width:640 height:480 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Route-Tour
6
+ layout:parallel
7
+ - script "We tour from the Golden Gate to the airport, stopping at each landmark."
8
+ - map view:route travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5
9
+ waypoints:[37.8199,-122.4783,"Golden Gate"; 37.7749,-122.4194,"Civic Center"; 37.6213,-122.3790,"SFO"]
10
+ - image src:https://picsum.photos/seed/gg-photo/200/200 at:"Golden Gate" duration:3 effects:[zoomIn]
11
+ - image src:https://picsum.photos/seed/civic-photo/200/200 at:"Civic Center" duration:3 effects:[zoomIn]
@@ -0,0 +1,9 @@
1
+ # video
2
+ seed:1
3
+ width:1080 height:1920 fps:30 layout:series transition:fade transitionTime:0.5
4
+
5
+ ## Multi-Leg-Trip
6
+ layout:parallel
7
+ - script "We fly to the coast, take a boat across the bay, walk the promenade, then drive to the airport."
8
+ - map view:route duration:12 travelMode:DRIVING mapType:roadmap routeColor:"#4285F4" routeWeight:5
9
+ 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"]