@lalalic/markcut 2.9.0 → 3.0.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.
@@ -33,7 +33,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
 
34
34
  // src/utils/index.ts
35
35
  function uid() {
36
- return Math.random().toString(36).slice(2, 10);
36
+ const raw = Math.random().toString(36).slice(2, 10);
37
+ const first = raw[0];
38
+ if (/^[0-9]/.test(first)) {
39
+ const letter = String.fromCharCode(97 + Math.floor(Math.random() * 26));
40
+ return letter + raw;
41
+ }
42
+ return raw;
37
43
  }
38
44
  function walkDown(node2, visit, parent = null, depth = 0) {
39
45
  const keep = visit(node2, parent, depth);
@@ -207,7 +213,8 @@ function wrapWithEffects(node2, result, parentKind) {
207
213
  const absStart = innerStream.start ?? 0;
208
214
  const absEnd = innerStream.end ?? result.duration;
209
215
  const duration = absEnd - absStart;
210
- const resetStream = {
216
+ const isBgNoEnd = innerStream.isBackground && innerStream.end == null;
217
+ const resetStream = isBgNoEnd ? { ...innerStream } : {
211
218
  ...innerStream,
212
219
  start: 0,
213
220
  end: duration,
@@ -224,28 +231,33 @@ function wrapWithEffects(node2, result, parentKind) {
224
231
  id: uid(),
225
232
  type: "effect",
226
233
  animation: spec.animation,
234
+ animationDurationSeconds: spec.duration,
227
235
  durationInSeconds: spec.duration,
228
236
  animationTimingFunction: spec.animationTimingFunction,
229
237
  animationIterationCount: spec.animationIterationCount ?? 1,
230
238
  customKeyframes: spec.customKeyframes,
231
239
  children: [currentStream],
232
- start: effStart,
233
- end: effEnd,
234
- visible: true,
240
+ // For background inner nodes: propagate start/end as-is so parent
241
+ // back-propagation fills the correct scene duration. The effect's
242
+ // durationInSeconds (animation spec) controls animation timing.
243
+ start: isOutermost && isBgNoEnd ? innerStream.start : effStart,
244
+ end: isOutermost && isBgNoEnd ? void 0 : effEnd,
245
+ visible: innerStream.visible ?? true,
235
246
  ...pickOn(node2)
236
247
  };
237
248
  }
238
249
  return { stream: currentStream, duration: result.duration };
239
250
  }
240
251
  function compileLeaf(node2, ctx, parentKind) {
241
- const id = node2.id ?? uid();
252
+ const id = node2.id;
253
+ const hasExplicitId = node2.id != null;
242
254
  const hasOwnDuration = typeof node2.duration === "number" || typeof node2.endAt === "number";
243
255
  const isBgNoOwnTiming = node2.isBackground && !hasOwnDuration;
244
256
  const start = isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
245
257
  const duration = isBgNoOwnTiming ? void 0 : deriveLeafDuration(node2, ctx);
246
258
  const end = duration != null ? start + duration : void 0;
247
259
  const base = {
248
- id,
260
+ ...id ? { id } : {},
249
261
  style: node2.style,
250
262
  visible: node2.visible ?? true,
251
263
  isBackground: node2.isBackground,
@@ -305,8 +317,7 @@ function compileLeaf(node2, ctx, parentKind) {
305
317
  const bindings = {};
306
318
  for (const key of Object.keys(node2)) {
307
319
  if (!KNOWN_COMPONENT_KEYS.has(key)) {
308
- const val = node2[key];
309
- if (typeof val === "string") bindings[key] = val;
320
+ bindings[key] = node2[key];
310
321
  }
311
322
  }
312
323
  const stream = {
@@ -407,13 +418,23 @@ function compileScene(node2, ctx, parentKind) {
407
418
  ];
408
419
  const sceneContentDuration = sceneKind === "parallel" ? aggregateDuration(compiledChildren, "parallel") : aggregateDuration(compiledChildren, sceneKind, resolved.time);
409
420
  const localDuration = Math.max(node2.duration ?? 0, sceneContentDuration);
410
- for (const c of compiledChildren) {
411
- if (c.stream.isBackground && c.stream.end == null) {
412
- c.stream.end = localDuration;
413
- c.stream.durationInSeconds = localDuration;
414
- if (c.stream.start == null) c.stream.start = 0;
421
+ function backpropagate(stream2, dur) {
422
+ if (stream2.end == null) {
423
+ stream2.end = dur;
424
+ if (stream2.durationInSeconds == null) {
425
+ stream2.durationInSeconds = dur;
426
+ }
427
+ if (stream2.start == null) stream2.start = 0;
428
+ }
429
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
430
+ for (const child of stream2.children) {
431
+ backpropagate(child, dur);
432
+ }
415
433
  }
416
434
  }
435
+ for (const c of compiledChildren) {
436
+ backpropagate(c.stream, localDuration);
437
+ }
417
438
  const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
418
439
  const end = start + localDuration;
419
440
  const stream = {
@@ -507,13 +528,23 @@ function compileContainer(node2, ctx, parentKind) {
507
528
  const resolved = node2.type === "transitionSeries" ? resolveTransition(node2.transition, node2.transitionTime) : { name: "fade", time: 0.5 };
508
529
  const children = compileChildren(node2.children, ctx, node2.type);
509
530
  const duration = aggregateDuration(children, node2.type, resolved.time);
510
- for (const c of children) {
511
- if (c.stream.isBackground && c.stream.end == null) {
512
- c.stream.end = duration;
513
- c.stream.durationInSeconds = duration;
514
- if (c.stream.start == null) c.stream.start = 0;
531
+ function backpropagate(stream2, dur) {
532
+ if (stream2.end == null) {
533
+ stream2.end = dur;
534
+ if (stream2.durationInSeconds == null) {
535
+ stream2.durationInSeconds = dur;
536
+ }
537
+ if (stream2.start == null) stream2.start = 0;
538
+ }
539
+ if (stream2.type === "effect" && Array.isArray(stream2.children)) {
540
+ for (const child of stream2.children) {
541
+ backpropagate(child, dur);
542
+ }
515
543
  }
516
544
  }
545
+ for (const c of children) {
546
+ backpropagate(c.stream, duration);
547
+ }
517
548
  const stream = {
518
549
  id,
519
550
  type: "folder",
@@ -10122,6 +10153,7 @@ var TYPE_TOKENS = {
10122
10153
  video: "video",
10123
10154
  audio: "audio",
10124
10155
  component: "component",
10156
+ event: "event",
10125
10157
  rhythm: "rhythm",
10126
10158
  include: "include",
10127
10159
  map: "map",
@@ -10341,6 +10373,21 @@ function parseNodeLine(content3, lineNum) {
10341
10373
  preserveVariantAttrs(node2, attrs);
10342
10374
  return node2;
10343
10375
  }
10376
+ case "event": {
10377
+ const node2 = {
10378
+ type: "component",
10379
+ id: attrs.id,
10380
+ jsx: "",
10381
+ duration: attrs.duration,
10382
+ start: attrs.start,
10383
+ instruction: attrs.instruction,
10384
+ style: attrs.style,
10385
+ effects: attrs.effects,
10386
+ on: attrs.on
10387
+ };
10388
+ preserveVariantAttrs(node2, attrs);
10389
+ return node2;
10390
+ }
10344
10391
  case "rhythm": {
10345
10392
  const src = firstPositional ?? attrs.src;
10346
10393
  if (!src) throw new DslError("rhythm requires src", ctx);
@@ -133,7 +133,7 @@ export type Image = z.infer<typeof image>;
133
133
  export const component = base.extend({
134
134
  type: z.literal("component").default("component"),
135
135
  jsx: z.string().describe("usage JSX expression compiled at runtime; tag names resolved from imports"),
136
- data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
136
+ data: z.record(z.string(), z.unknown()).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
137
137
  });
138
138
  export type Component = z.infer<typeof component>;
139
139
 
@@ -143,6 +143,7 @@ export type Component = z.infer<typeof component>;
143
143
  export const effect = base.extend({
144
144
  type: z.literal("effect").default("effect"),
145
145
  animation: z.string().optional().describe("builtin keyframe name or 'custom'"),
146
+ animationDurationSeconds: z.number().optional().describe("animation duration (separate from wrapper durationInSeconds which getDurationInSeconds may overwrite)"),
146
147
  animationTimingFunction: z
147
148
  .enum(["linear", "ease", "ease-in", "ease-out", "ease-in-out"])
148
149
  .optional(),
@@ -72,7 +72,30 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
72
72
  [stream.data, components, eventState],
73
73
  );
74
74
 
75
- if (!stream.jsx) return null;
75
+ if (!stream.jsx) {
76
+ // Event-only stub: no JSX to render, but may fire events on `on`.
77
+ // Still needs EventAwareComponent for useFrameEvents to register
78
+ // and fire at the right frame.
79
+ const start = stream.start ?? 0;
80
+ const end = stream.end ?? start + (stream.duration ?? 1);
81
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
82
+ return (
83
+ <Sequence
84
+ durationInFrames={durFrames}
85
+ from={Math.floor(fps * start)}
86
+ layout="none"
87
+ >
88
+ <EventAwareComponent
89
+ jsx=""
90
+ components={components}
91
+ data={bindings}
92
+ action={{ start, end }}
93
+ durFrames={durFrames}
94
+ on={stream.on}
95
+ />
96
+ </Sequence>
97
+ );
98
+ }
76
99
 
77
100
  const start = stream.start ?? 0;
78
101
  const end = stream.end ?? start + (stream.duration ?? 1);
@@ -118,6 +141,9 @@ function EventAwareComponent({
118
141
  // Fire events at the right frame for this node's timeline
119
142
  useFrameEvents(on, durFrames);
120
143
 
144
+ // If no JSX, this is an event-only stub — nothing to render
145
+ if (!jsx) return null;
146
+
121
147
  return (
122
148
  <TweenedJsxParser
123
149
  jsx={jsx}
@@ -34,17 +34,24 @@ export function EffectWrapper({
34
34
  const durationInFrames = end - start;
35
35
  if (durationInFrames <= 0) return [] as Record<string, string>[];
36
36
 
37
+ // Use animationDurationSeconds for animation timing when available
38
+ // (set by wrapWithEffects for background nodes where end is set to parent
39
+ // duration but the animation spec duration is preserved separately).
40
+ const animDurationSec = stream.animationDurationSeconds ?? stream.durationInSeconds ?? (durationInFrames / fps);
41
+ const animDurationFrames = Math.ceil(animDurationSec * fps);
42
+
37
43
  const animation = stream.animation;
38
44
  const timingFn = stream.animationTimingFunction;
39
45
  const iterCount = stream.animationIterationCount ?? 1;
40
46
  const style = (cssJS(stream.style) ?? {}) as Record<string, string>;
41
47
 
42
- // Handle iteration count: loop the animation within the span
48
+ // Handle iteration count: loop the animation within the span.
49
+ // The animation period is animDurationFrames (not the full span duration).
43
50
  let currentFrame = frame;
44
- if (iterCount > 0 && durationInFrames > 0) {
45
- const iteration = Math.floor((frame - start) / durationInFrames);
51
+ if (iterCount > 0 && animDurationFrames > 0) {
52
+ const iteration = Math.floor((frame - start) / animDurationFrames);
46
53
  if (iteration < iterCount) {
47
- currentFrame = start + ((frame - start) % durationInFrames);
54
+ currentFrame = start + ((frame - start) % animDurationFrames);
48
55
  }
49
56
  }
50
57
 
@@ -56,7 +63,7 @@ export function EffectWrapper({
56
63
  if (config) {
57
64
  const animStyle = interpolateKeyframes(config, actionFrame, {
58
65
  fps,
59
- durationInSeconds: durationInFrames / fps,
66
+ durationInSeconds: animDurationSec,
60
67
  timingFunction: timingFn,
61
68
  });
62
69
  if (animStyle) Object.assign(style, animStyle);
@@ -65,7 +72,7 @@ export function EffectWrapper({
65
72
  }
66
73
 
67
74
  return Object.keys(style).length > 0 ? [style] : [];
68
- }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style]);
75
+ }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style, stream.animationDurationSeconds, stream.durationInSeconds]);
69
76
 
70
77
  if (styles.length === 0) return <>{children}</>;
71
78
 
@@ -62,7 +62,7 @@ export function FolderLeaf({ stream }: { stream: FolderStream }) {
62
62
  // Background children are rendered outside the series (parallel overlays),
63
63
  // so TransitionSeries doesn't reject the <Loop> wrapper.
64
64
  const bgChildren = visibleChildren.filter((c) => c.isBackground);
65
- const seriesChildren = isSeries ? visibleChildren.filter((c) => !c.isBackground) : visibleChildren;
65
+ const seriesChildren = visibleChildren.filter((c) => !c.isBackground);
66
66
 
67
67
  // When all non-background series children are audio, skip transitions to
68
68
  // avoid audio overlap (both audio tracks play simultaneously during a fade).
@@ -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++) {
@@ -0,0 +1,40 @@
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:parallel
17
+ - component id:flowChart duration:12
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
+ 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"])
@@ -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,9 @@
1
+ {
2
+ "test-clip": {
3
+ "width": 320,
4
+ "height": 240,
5
+ "created": "0000-00-00T00:00:00Z",
6
+ "location": null,
7
+ "duration": 3
8
+ }
9
+ }
@@ -0,0 +1,5 @@
1
+ WEBVTT
2
+
3
+ 00:00.000 --> 00:01.100
4
+ Thank you.
5
+