@lalalic/markcut 2.9.0 → 3.1.0

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 (36) hide show
  1. package/package.json +1 -1
  2. package/skills/markcut/SKILL.md +7 -0
  3. package/skills/markcut/docs/components.md +45 -2
  4. package/skills/markcut/docs/map-dynamic-camera.md +244 -0
  5. package/skills/markcut/docs/markdown-descriptive.md +7 -2
  6. package/src/components/Markdown.tsx +138 -24
  7. package/src/components/Mermaid.tsx +223 -22
  8. package/src/context/EventContext.tsx +3 -0
  9. package/src/descriptive/compiler.ts +105 -29
  10. package/src/descriptive/dsl.ts +42 -5
  11. package/src/descriptive/markdown.ts +23 -0
  12. package/src/descriptive/resolve.test.ts +5 -5
  13. package/src/descriptive/resolve.ts +51 -12
  14. package/src/player/bundle/player.js +751 -143
  15. package/src/player/pipeline.mjs +130 -32
  16. package/src/player/pipeline.ts +5 -4
  17. package/src/player/server.mjs +22 -42
  18. package/src/render/cli.mjs +54 -3
  19. package/src/render/validate-assets.mjs +140 -0
  20. package/src/schema/index.ts +58 -2
  21. package/src/spots/cli.mjs +266 -0
  22. package/src/types/Component.tsx +27 -1
  23. package/src/types/Effect.tsx +13 -6
  24. package/src/types/Folder.tsx +1 -1
  25. package/src/types/Map.tsx +501 -127
  26. package/src/utils/index.ts +14 -2
  27. package/src/utils/tween.ts +49 -1
  28. package/tests/dsl.test.ts +43 -0
  29. package/tests/fixtures/map-dynamic.json +52 -0
  30. package/tests/fixtures/md/animate-diagrams.md +42 -0
  31. package/tests/fixtures/md/electricity-grow.md +130 -0
  32. package/tests/fixtures/md/map-all-views.md +28 -0
  33. package/tests/md-descriptive.test.ts +58 -0
  34. package/tests/render.test.ts +1 -0
  35. package/tests/schema.test.ts +58 -1
  36. package/tests/validate-assets.test.ts +106 -0
@@ -4,7 +4,15 @@
4
4
  */
5
5
 
6
6
  export function uid(): string {
7
- return Math.random().toString(36).slice(2, 10);
7
+ // Ensure the first character is a letter (valid JS identifier start)
8
+ const raw = Math.random().toString(36).slice(2, 10);
9
+ const first = raw[0]!;
10
+ // If first char is a digit, prepend a random letter
11
+ if (/^[0-9]/.test(first)) {
12
+ const letter = String.fromCharCode(97 + Math.floor(Math.random() * 26));
13
+ return letter + raw;
14
+ }
15
+ return raw;
8
16
  }
9
17
 
10
18
  const KEBAB = /[^a-zA-Z0-9_-]+/g;
@@ -135,7 +143,11 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
135
143
  getDurationInSeconds(child, update);
136
144
  }
137
145
 
138
- const visible = stream.children.filter((c) => !c.isBackground);
146
+ // Effect wrappers encompass all children visually; background children
147
+ // still contribute to the effect's visible duration.
148
+ const visible = stream.type === "effect"
149
+ ? stream.children
150
+ : stream.children.filter((c) => !c.isBackground);
139
151
  if (stream.isSeries) {
140
152
  const overlap = stream.transition ? (stream.transitionTime ?? 0.5) : 0;
141
153
  for (let i = 0; i < visible.length; i++) {
@@ -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,19 @@ 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
+
103
116
  it("returns empty array for non-bracket input", () => {
104
117
  expect(parseWaypoints("not a list")).toEqual([]);
105
118
  });
@@ -133,6 +146,36 @@ describe("dsl — parseProps", () => {
133
146
  it("returns {} for unclosed object", () => {
134
147
  expect(parseProps("{a:1")).toEqual({});
135
148
  });
149
+
150
+ it("parses tween() expressions into tagged specs", () => {
151
+ expect(parseProps("{zoom:tween(6, 12)}")).toEqual({ zoom: { __tween: [6, 12] } });
152
+ });
153
+
154
+ it("parses tween() with easing", () => {
155
+ expect(parseProps("{zoom:tween(6, 12, easeInOut)}")).toEqual({
156
+ zoom: { __tween: [6, 12, "easeInOut"] },
157
+ });
158
+ });
159
+
160
+ it("parses tween() nested inside objects and arrays", () => {
161
+ expect(parseProps("{center:{lat:tween(37.0, 37.9), lng:120}, pov:{heading:tween(200, 320)}}")).toEqual({
162
+ center: { lat: { __tween: [37.0, 37.9] }, lng: 120 },
163
+ pov: { heading: { __tween: [200, 320] } },
164
+ });
165
+ });
166
+
167
+ it("parses tween() with quoted string values (colors)", () => {
168
+ expect(parseProps('{color:tween("#000000", "#FFFFFF", easeOut)}')).toEqual({
169
+ color: { __tween: ["#000000", "#FFFFFF", "easeOut"] },
170
+ });
171
+ });
172
+
173
+ it("keeps static numbers alongside tweens", () => {
174
+ expect(parseProps("{zoom:tween(6, 12), tilt:45}")).toEqual({
175
+ zoom: { __tween: [6, 12] },
176
+ tilt: 45,
177
+ });
178
+ });
136
179
  });
137
180
 
138
181
  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,42 @@
1
+ # video
2
+ seed:2660286133
3
+ width:640 height:480 fps:30 layout:series
4
+
5
+ ## Title
6
+ layout:parallel
7
+ - component duration:3
8
+ ~~~jsx jsx
9
+ <div style={{position:'absolute',top:0,left:0,width:640,height:480,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'linear-gradient(135deg,#1a1a2e,#16213e)'}}>
10
+ <h1 style={{color:'#00d4ff',fontSize:28,margin:0}}>Animated Diagrams</h1>
11
+ <p style={{color:'#aaa',fontSize:16,marginTop:10}}>Mermaid Diagrams with Dynamic Highlight</p>
12
+ </div>
13
+ ~~~
14
+
15
+ ## FlowChart
16
+ layout:series
17
+ - component id:flowChart isBackground:true
18
+ ~~~jsx
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
+ <p style={{color:'#00d4ff',fontSize:12,textAlign:'center',margin:'0 0 4px 0',flexShrink:0}}>Flow — {highlight}</p>
21
+ <div style={{flex:1,display:'flex',justifyContent:'center',alignItems:'center',overflow:'hidden'}}>
22
+ <Mermaid highlight={highlight} animateEdges={animateEdges} theme='dark' source={mermaid}/>
23
+ </div>
24
+ </div>
25
+ ~~~
26
+ ~~~mermaid
27
+ graph TD
28
+ A["Receive Request"] --> B["Validate Input"]
29
+ B --> C{"Valid?"}
30
+ C -->|Yes| D["Process Data"]
31
+ C -->|No| E["Return Error"]
32
+ D --> F["Format Response"]
33
+ F --> G["Send Response"]
34
+ classDef highlight fill:#ffd700,stroke:#ff6600,stroke-width:3px,color:#000
35
+ ~~~
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,130 @@
1
+ # video
2
+ seed:1793769080
3
+ width:640 height:480 fps:30 layout:series
4
+
5
+ ## Title
6
+ layout:parallel
7
+ - component duration:3
8
+ ~~~jsx jsx
9
+ <div style={{width:'100%',height:'100%',display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'linear-gradient(135deg,#0c0c1d,#1a1a3e)'}}>
10
+ <h1 style={{color:'#ffd700',fontSize:36,margin:0}}>Global Electricity Generation</h1>
11
+ <p style={{color:'#88ccff',fontSize:20,marginTop:10}}>1985 → 2025</p>
12
+ <p style={{color:'#aaa',fontSize:14,marginTop:20}}>Data: Ember / Energy Institute (Our World in Data)</p>
13
+ </div>
14
+ ~~~
15
+
16
+ ## ChinaSurge
17
+ layout:parallel
18
+ - component duration:6
19
+ ~~~jsx jsx
20
+ <div style={{width:'100%',height:'100%',background:'#0c0c1d',padding:20,fontFamily:'Arial,sans-serif'}}>
21
+ <h2 style={{color:'#ffd700',fontSize:18,textAlign:'center',margin:'0 0 10px 0'}}>China's Electricity Generation (TWh)</h2>
22
+ <svg viewBox='0 0 580 380' width='100%' height='85%'>
23
+ {[1985,1990,1995,2000,2005,2010,2015,2020,2025].map((year,i)=>(
24
+ <g key={year}>
25
+ <rect x={20+i*62} y={350-tween(0,{1985:42,1990:63,1995:97,2000:137,2005:253,2010:425,2015:625,2020:786,2025:1069}[year])} width={42} height={tween(0,{1985:42,1990:63,1995:97,2000:137,2005:253,2010:425,2015:625,2020:786,2025:1069}[year])} fill='#ff4444' rx={3} />
26
+ <text x={20+i*62+21} y={365} textAnchor='middle' fill='#aaa' fontSize={9}>{year}</text>
27
+ <text x={20+i*62+21} y={345-tween(0,{1985:42,1990:63,1995:97,2000:137,2005:253,2010:425,2015:625,2020:786,2025:1069}[year])} textAnchor='middle' fill='#ff8888' fontSize={9}>{Math.round({1985:411,1990:621,1995:928,2000:1356,2005:2500,2010:4207,2015:6186,2020:7779,2025:10583}[year])}</text>
28
+ </g>
29
+ ))}
30
+ <text x={10} y={20} fill='#666' fontSize={10}>TWh</text>
31
+ </svg>
32
+ </div>
33
+ ~~~
34
+
35
+ ## World1985
36
+ layout:parallel
37
+ - component duration:5
38
+ ~~~jsx jsx
39
+ <div style={{width:'100%',height:'100%',background:'#0c0c1d',padding:20,fontFamily:'Arial,sans-serif'}}>
40
+ <h2 style={{color:'#ffd700',fontSize:18,textAlign:'center',margin:'0 0 10px 0'}}>Electricity Generation in 1985 (TWh)</h2>
41
+ <svg viewBox='0 0 580 380' width='100%' height='85%'>
42
+ {[
43
+ {name:'USA',val:2657,color:'#4477ff',scale:266},
44
+ {name:'Russia',val:962,color:'#cc4444',scale:96},
45
+ {name:'Japan',val:672,color:'#44cc44',scale:67},
46
+ {name:'Germany',val:523,color:'#ffaa00',scale:52},
47
+ {name:'Canada',val:459,color:'#ff66aa',scale:46},
48
+ {name:'France',val:344,color:'#aa66ff',scale:34},
49
+ {name:'UK',val:298,color:'#66cccc',scale:30},
50
+ {name:'Brazil',val:194,color:'#66ff66',scale:19},
51
+ {name:'India',val:186,color:'#ff8844',scale:19},
52
+ {name:'China',val:411,color:'#ff4444',scale:41},
53
+ ].map((c,i)=>(
54
+ <g key={c.name}>
55
+ <rect x={20+i*54} y={350-tween(0,c.scale)} width={40} height={tween(0,c.scale)} fill={c.color} rx={3}>
56
+ <animate attributeName='opacity' values='0;1' dur='2s' fill='freeze'/>
57
+ </rect>
58
+ <text x={20+i*54+20} y={365} textAnchor='middle' fill='#aaa' fontSize={9}>{c.name}</text>
59
+ <text x={20+i*54+20} y={345-tween(0,c.scale)} textAnchor='middle' fill='#fff' fontSize={9}>{c.val}</text>
60
+ </g>
61
+ ))}
62
+ </svg>
63
+ </div>
64
+ ~~~
65
+
66
+ ## World2025
67
+ layout:parallel
68
+ - component duration:5
69
+ ~~~jsx jsx
70
+ <div style={{width:'100%',height:'100%',background:'#0c0c1d',padding:20,fontFamily:'Arial,sans-serif'}}>
71
+ <h2 style={{color:'#ffd700',fontSize:18,textAlign:'center',margin:'0 0 10px 0'}}>Electricity Generation in 2025 (TWh)</h2>
72
+ <svg viewBox='0 0 580 380' width='100%' height='85%'>
73
+ {[
74
+ {name:'China',val:10583,color:'#ff4444',scale:280},
75
+ {name:'USA',val:4520,color:'#4477ff',scale:120},
76
+ {name:'India',val:2082,color:'#ff8844',scale:55},
77
+ {name:'Russia',val:1193,color:'#cc4444',scale:32},
78
+ {name:'Japan',val:1030,color:'#44cc44',scale:27},
79
+ {name:'Brazil',val:751,color:'#66ff66',scale:20},
80
+ {name:'Canada',val:652,color:'#ff66aa',scale:17},
81
+ {name:'S.Korea',val:625,color:'#66cccc',scale:17},
82
+ {name:'Germany',val:500,color:'#ffaa00',scale:13},
83
+ {name:'France',val:570,color:'#aa66ff',scale:15},
84
+ ].map((c,i)=>(
85
+ <g key={c.name}>
86
+ <rect x={20+i*54} y={350-tween(0,c.scale)} width={40} height={tween(0,c.scale)} fill={c.color} rx={3}/>
87
+ <text x={20+i*54+20} y={365} textAnchor='middle' fill='#aaa' fontSize={9}>{c.name}</text>
88
+ <text x={20+i*54+20} y={345-tween(0,c.scale)} textAnchor='middle' fill='#fff' fontSize={9}>{c.val}</text>
89
+ </g>
90
+ ))}
91
+ </svg>
92
+ </div>
93
+ ~~~
94
+
95
+ ## GrowthComparison
96
+ layout:parallel
97
+ - component duration:6
98
+ ~~~jsx jsx
99
+ <div style={{width:'100%',height:'100%',background:'#0c0c1d',padding:20,fontFamily:'Arial,sans-serif'}}>
100
+ <h2 style={{color:'#ffd700',fontSize:18,textAlign:'center',margin:'0 0 10px 0'}}>Growth: 1985 → 2025 (TWh)</h2>
101
+ <svg viewBox='0 0 580 380' width='100%' height='85%'>
102
+ {[
103
+ {name:'China',v1985:411,v2025:10583,growth:'+10172',color:'#ff4444',s1985:11,s2025:280},
104
+ {name:'USA',v1985:2657,v2025:4520,growth:'+1863',color:'#4477ff',s1985:70,s2025:120},
105
+ {name:'India',v1985:186,v2025:2082,growth:'+1896',color:'#ff8844',s1985:5,s2025:55},
106
+ {name:'Brazil',v1985:194,v2025:751,growth:'+557',color:'#66ff66',s1985:5,s2025:20},
107
+ ].map((c,i)=>(
108
+ <g key={c.name}>
109
+ <text x={20} y={50+i*80} fill='#aaa' fontSize={14}>{c.name}</text>
110
+ <rect x={20} y={58+i*80} width={tween(0,c.s1985)} height={14} fill={c.color} opacity={0.6} rx={2}/>
111
+ <rect x={20+tween(0,c.s1985)} y={58+i*80} width={tween(0,c.s2025-c.s1985)} height={14} fill={c.color} rx={2}/>
112
+ <text x={20+tween(0,c.s2025)+5} y={70+i*80} fill='#fff' fontSize={11}>{c.v1985} → {c.v2025}</text>
113
+ <text x={20} y={86+i*80} fill='#ffd700' fontSize={10}>Growth: {c.growth} TWh</text>
114
+ </g>
115
+ ))}
116
+ <text x={20} y={370} fill='#666' fontSize={9}>1985 bar (dim) → 2025 bar (bright)</text>
117
+ </svg>
118
+ </div>
119
+ ~~~
120
+
121
+ ## Closing
122
+ layout:parallel
123
+ - component duration:3
124
+ ~~~jsx jsx
125
+ <div style={{width:'100%',height:'100%',display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'linear-gradient(135deg,#0c0c1d,#1a1a3e)'}}>
126
+ <h2 style={{color:'#ffd700',fontSize:24,margin:0}}>China leads at 10,583 TWh</h2>
127
+ <p style={{color:'#88ccff',fontSize:16,marginTop:10}}>nearly 25x growth since 1985</p>
128
+ <p style={{color:'#aaa',fontSize:12,marginTop:30}}>Data: Our World in Data · Ember (2026) / Energy Institute (2025)</p>
129
+ </div>
130
+ ~~~
@@ -0,0 +1,28 @@
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
+ ## Cinematic
16
+ layout:parallel
17
+ - script "The camera tilts and chases the road like a drone."
18
+ - 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"]
19
+
20
+ ## Street-View
21
+ layout:parallel
22
+ - script "And finally, we land on the street itself."
23
+ - 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)}
24
+
25
+ ## Street-View-Walk
26
+ layout:parallel
27
+ - script "A quick walk down the block."
28
+ - map view:streetview duration:6 streetView:{route:[{lat:37.7793,lng:-122.4193}, {lat:37.7785,lng:-122.4185}, {lat:37.7777,lng:-122.4178}, {lat:37.7769,lng:-122.4170}], radius:50, pov:{heading:tween(0, 40, easeInOut), pitch:-5}}
@@ -897,6 +897,64 @@ 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
+
900
958
  // ── Helper ────────────────────────────────────────────────────────────────
901
959
 
902
960
  function findComponents(node: any): any[] {
@@ -385,6 +385,7 @@ describe("Full Feature Combination", () => {
385
385
  "effects.json",
386
386
  "subtitle.json",
387
387
  "map.json",
388
+ "map-dynamic.json",
388
389
  "audio.json",
389
390
  "subvideo.json",
390
391
  "full.json",
@@ -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,60 @@ 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
+ });
@@ -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
+ });