@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.
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +7 -0
- package/skills/markcut/docs/components.md +45 -2
- package/skills/markcut/docs/map-dynamic-camera.md +244 -0
- package/skills/markcut/docs/markdown-descriptive.md +7 -2
- package/src/components/Markdown.tsx +138 -24
- package/src/components/Mermaid.tsx +223 -22
- package/src/context/EventContext.tsx +3 -0
- package/src/descriptive/compiler.ts +105 -29
- package/src/descriptive/dsl.ts +42 -5
- package/src/descriptive/markdown.ts +23 -0
- package/src/descriptive/resolve.test.ts +5 -5
- package/src/descriptive/resolve.ts +51 -12
- package/src/player/bundle/player.js +751 -143
- package/src/player/pipeline.mjs +130 -32
- 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 +58 -2
- package/src/spots/cli.mjs +266 -0
- package/src/types/Component.tsx +27 -1
- package/src/types/Effect.tsx +13 -6
- package/src/types/Folder.tsx +1 -1
- package/src/types/Map.tsx +501 -127
- package/src/utils/index.ts +14 -2
- package/src/utils/tween.ts +49 -1
- package/tests/dsl.test.ts +43 -0
- package/tests/fixtures/map-dynamic.json +52 -0
- package/tests/fixtures/md/animate-diagrams.md +42 -0
- package/tests/fixtures/md/electricity-grow.md +130 -0
- package/tests/fixtures/md/map-all-views.md +28 -0
- package/tests/md-descriptive.test.ts +58 -0
- package/tests/render.test.ts +1 -0
- package/tests/schema.test.ts +58 -1
- package/tests/validate-assets.test.ts +106 -0
package/src/utils/index.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export function uid(): string {
|
|
7
|
-
|
|
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
|
-
|
|
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++) {
|
package/src/utils/tween.ts
CHANGED
|
@@ -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[] {
|
package/tests/render.test.ts
CHANGED
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,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
|
+
});
|