@lalalic/markcut 1.2.0 → 2.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 (61) hide show
  1. package/AGENTS.md +40 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -1
  7. package/docs/system-prompt-edit.md +19 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1149 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +60 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +336 -239
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
@@ -31,3 +31,12 @@ layout:parallel
31
31
  - image src:a.jpg duration:3 start:1
32
32
  - image src:b.jpg duration:2 start:0
33
33
  - image src:c.jpg duration:4 start:2
34
+ ## ScriptCodeFence
35
+ layout:series
36
+ - script
37
+ ~~~script
38
+ This is a longer narration
39
+ that goes across multiple lines
40
+ and uses a code fence instead of inline text.
41
+ ~~~
42
+ - script "Inline text also works"
@@ -18,7 +18,8 @@
18
18
  "id": "scene-1-headline",
19
19
  "type": "component",
20
20
  "jsx": "<div style={{background:'linear-gradient(135deg,#667eea,#764ba2)',padding:'30px',borderRadius:'12px',textAlign:'center'}}><h2 style={{color:'white',fontSize:'28px',margin:0}}>Scene Rendering Works</h2><p style={{color:'rgba(255,255,255,0.7)',fontSize:'14px',marginTop:'8px'}}>Testing the scene type in stream tree</p></div>",
21
- "actions": [{ "start": 0, "end": 3 }]
21
+ "start": 0,
22
+ "end": 3
22
23
  }
23
24
  ]
24
25
  },
@@ -32,7 +33,8 @@
32
33
  "id": "scene-2-headline",
33
34
  "type": "component",
34
35
  "jsx": "<div style={{background:'linear-gradient(135deg,#764ba2,#667eea)',padding:'30px',borderRadius:'12px',textAlign:'center'}}><h2 style={{color:'white',fontSize:'28px',margin:0}}>Test Complete</h2><p style={{color:'rgba(255,255,255,0.7)',fontSize:'14px',marginTop:'8px'}}>All scene types render correctly</p></div>",
35
- "actions": [{ "start": 0, "end": 2 }]
36
+ "start": 0,
37
+ "end": 2
36
38
  }
37
39
  ]
38
40
  }
@@ -23,7 +23,8 @@
23
23
  "type": "image",
24
24
  "src": "https://picsum.photos/seed/sub1/640/480",
25
25
  "fit": "cover",
26
- "actions": [{ "start": 0, "end": 4 }]
26
+ "start": 0,
27
+ "end": 4
27
28
  }
28
29
  ]
29
30
  },
@@ -37,7 +38,8 @@
37
38
  "type": "image",
38
39
  "src": "https://picsum.photos/seed/sub2/640/480",
39
40
  "fit": "cover",
40
- "actions": [{ "start": 0, "end": 4 }]
41
+ "start": 0,
42
+ "end": 4
41
43
  }
42
44
  ]
43
45
  },
@@ -51,7 +53,8 @@
51
53
  "type": "image",
52
54
  "src": "https://picsum.photos/seed/sub3/640/480",
53
55
  "fit": "cover",
54
- "actions": [{ "start": 0, "end": 5 }]
56
+ "start": 0,
57
+ "end": 5
55
58
  }
56
59
  ]
57
60
  }
@@ -18,13 +18,15 @@
18
18
  "type": "image",
19
19
  "src": "https://picsum.photos/seed/subv-intro/640/480",
20
20
  "fit": "cover",
21
- "actions": [{ "start": 0, "end": 2 }]
21
+ "start": 0,
22
+ "end": 2
22
23
  },
23
24
  {
24
25
  "id": "title-intro",
25
26
  "type": "component",
26
27
  "jsx": "<div style={{background:'#667eea',color:'white',padding:'30px',borderRadius:'12px',textAlign:'center'}}><h2 style={{margin:0,fontSize:'28px'}}>Main Video</h2></div>",
27
- "actions": [{ "start": 0, "end": 2 }]
28
+ "start": 0,
29
+ "end": 2
28
30
  }
29
31
  ]
30
32
  },
@@ -32,8 +34,9 @@
32
34
  "id": "subvideo-chapter",
33
35
  "type": "include",
34
36
  "name": "Embedded-Reel",
35
- "src": "data:application/json;base64,eyJpZCI6InJvb3QiLCJ0eXBlIjoicm9vdCIsIndpZHRoIjo2NDAsImhlaWdodCI6NDgwLCJmcHMiOjMwLCJpc1NlcmllcyI6dHJ1ZSwidHJhbnNpdGlvbiI6ImZhZGUiLCJ0cmFuc2l0aW9uVGltZSI6MC4zLCJjaGlsZHJlbiI6W3siaWQiOiJzdWItc2NlbmUtMSIsInR5cGUiOiJmb2xkZXIiLCJpc1NlcmllcyI6ZmFsc2UsImNoaWxkcmVuIjpbeyJpZCI6InN1Yi1iZy0xIiwidHlwZSI6ImltYWdlIiwic3JjIjoiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvc3Vidi1pbm5lcjEvNjQwLzQ4MCIsImZpdCI6ImNvdmVyIiwiYWN0aW9ucyI6W3sic3RhcnQiOjAsImVuZCI6Mn1dfV19LHsiaWQiOiJzdWItc2NlbmUtMiIsInR5cGUiOiJmb2xkZXIiLCJpc1NlcmllcyI6ZmFsc2UsImNoaWxkcmVuIjpbeyJpZCI6InN1Yi1iZy0yIiwidHlwZSI6ImltYWdlIiwic3JjIjoiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvc3Vidi1pbm5lcjIvNjQwLzQ4MCIsImZpdCI6ImNvdmVyIiwiYWN0aW9ucyI6W3sic3RhcnQiOjAsImVuZCI6Mn1dfV19XX0=",
36
- "actions": [{ "start": 0, "end": 4 }]
37
+ "src": "data:application/json;base64,eyJpZCI6InJvb3QiLCJ0eXBlIjoicm9vdCIsIndpZHRoIjo2NDAsImhlaWdodCI6NDgwLCJmcHMiOjMwLCJpc1NlcmllcyI6dHJ1ZSwidHJhbnNpdGlvbiI6ImZhZGUiLCJ0cmFuc2l0aW9uVGltZSI6MC4zLCJjaGlsZHJlbiI6W3siaWQiOiJzdWItc2NlbmUtMSIsInR5cGUiOiJmb2xkZXIiLCJpc1NlcmllcyI6ZmFsc2UsImNoaWxkcmVuIjpbeyJpZCI6InN1Yi1iZy0xIiwidHlwZSI6ImltYWdlIiwic3JjIjoiaHR0cHM6Ly9waWNzdW0ucGhvdG9zL3NlZWQvc3Vidi1pbm5lcjEvNjQwLzQ4MCIsImZpdCI6ImNvdmVyIiwic3RhcnQiOjAsImVuZCI6Mn1dfSx7ImlkIjoic3ViLXNjZW5lLTIiLCJ0eXBlIjoiZm9sZGVyIiwiaXNTZXJpZXMiOmZhbHNlLCJjaGlsZHJlbiI6W3siaWQiOiJzdWItYmctMiIsInR5cGUiOiJpbWFnZSIsInNyYyI6Imh0dHBzOi8vcGljc3VtLnBob3Rvcy9zZWVkL3N1YnYtaW5uZXIyLzY0MC80ODAiLCJmaXQiOiJjb3ZlciIsInN0YXJ0IjowLCJlbmQiOjJ9XX1dfQ==",
38
+ "start": 0,
39
+ "end": 4
37
40
  },
38
41
  {
39
42
  "id": "outro",
@@ -45,13 +48,15 @@
45
48
  "type": "image",
46
49
  "src": "https://picsum.photos/seed/subv-outro/640/480",
47
50
  "fit": "cover",
48
- "actions": [{ "start": 0, "end": 2 }]
51
+ "start": 0,
52
+ "end": 2
49
53
  },
50
54
  {
51
55
  "id": "title-outro",
52
56
  "type": "component",
53
57
  "jsx": "<div style={{background:'#764ba2',color:'white',padding:'30px',borderRadius:'12px',textAlign:'center'}}><h2 style={{margin:0,fontSize:'28px'}}>Back to Main</h2></div>",
54
- "actions": [{ "start": 0, "end": 2 }]
58
+ "start": 0,
59
+ "end": 2
55
60
  }
56
61
  ]
57
62
  }
@@ -8,17 +8,67 @@ layout:series subtitle:{fontSize:"20px"} width:1920 height:1080 fps:30 tts:"edge
8
8
  ~~~js imports
9
9
  import ReactMarkdown from 'react-markdown';
10
10
  import remarkGfm from 'remark-gfm'
11
+ import mermaid from 'mermaid'
12
+ import { delayRender, continueRender } from 'remotion'
13
+ import React from "react"
14
+
15
+ // Initialize mermaid once. Theme matches the slide deck's dark scheme.
16
+ mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' })
17
+
18
+ /**
19
+ * Renders a mermaid diagram as inline SVG.
20
+ * Uses Remotion's delayRender/continueRender so the diagram is guaranteed
21
+ * to be ready before the frame is captured.
22
+ */
23
+ export function Mermaid({ source }) {
24
+ const ref = React.useRef(null)
25
+
26
+ React.useEffect(() => {
27
+ if (!source || !ref.current) return
28
+ const handle = delayRender('Mermaid rendering')
29
+ mermaid.render('mmd-' + Math.random().toString(36).slice(2), source)
30
+ .then((result) => {
31
+ if (ref.current) ref.current.innerHTML = result.svg
32
+ continueRender(handle)
33
+ })
34
+ .catch((err) => {
35
+ console.error('Mermaid error:', err)
36
+ continueRender(handle)
37
+ })
38
+ }, [source])
11
39
 
40
+ return (
41
+ <div
42
+ ref={ref}
43
+ style={{ width: '100%', maxWidth: 960, margin: '20px auto' }}
44
+ />
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Courseware slide component.
50
+ * - Renders markdown via react-markdown
51
+ * - Highlights the Nth bullet when `current=N`
52
+ * - Renders mermaid code blocks ( ```mermaid ...``` ) as inline diagrams
53
+ */
12
54
  export function Slide({ current = 0, children }) {
13
- let idx = 1;
55
+ let idx = 1
14
56
  return (
15
57
  <div className="slide">
16
58
  <ReactMarkdown remarkPlugins={[remarkGfm]}
17
59
  components={{
18
60
  li: ({ children }) => {
19
- const highlight = idx === current; idx++;
20
- return <li className={highlight ? 'highlight' : ''}>{children}</li>;
21
- }
61
+ const highlight = idx === current; idx++
62
+ return <li className={highlight ? 'highlight' : ''}>{children}</li>
63
+ },
64
+ // Fenced code blocks — detect mermaid, render as diagram
65
+ pre: ({ children }) => {
66
+ const code = React.Children.toArray(children)[0]
67
+ if (code?.props?.className === 'language-mermaid') {
68
+ return <Mermaid source={String(code.props.children)} />
69
+ }
70
+ return <pre>{children}</pre>
71
+ },
22
72
  }}>{children}</ReactMarkdown>
23
73
  </div>
24
74
  )
@@ -128,6 +178,13 @@ layout:parallel
128
178
  Introduction to Machine Learning
129
179
 
130
180
  **Dr. AI** | AI Introductory Course
181
+ ```mermaid
182
+ graph TD;
183
+ A-->B;
184
+ A-->C;
185
+ B-->D;
186
+ C-->D;
187
+ ```
131
188
  ~~~
132
189
  ~~~md zh-source
133
190
  # 机器学习导论
@@ -15,12 +15,8 @@
15
15
  "id": "demo-tween",
16
16
  "type": "component",
17
17
  "jsx": "<div style={{width:'100%',height:'100%',display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'#1a1a2e',overflow:'hidden'}}><div style={{padding:'40px 60px',borderRadius:'20px',background:'linear-gradient(135deg,#667eea,#764ba2)',boxShadow:'0 8px 32px rgba(102,126,234,0.4)',opacity:tween(0,1),transform:'scale('+tween(0.3,1.2)+') rotate('+tween(-10,5)+'deg)'}}><span style={{color:'white',fontSize:tween(24,72)+'px',fontWeight:'bold',fontFamily:'sans-serif'}}>Hello Tween!</span></div><div style={{marginTop:'30px',opacity:tween(0,1),color:'rgba(255,255,255,0.6)',fontSize:'16px',fontFamily:'monospace'}}>frame: {useCurrentFrame()} / {useVideoConfig().durationInFrames}</div></div>",
18
- "actions": [
19
- {
20
- "start": 0,
21
- "end": 4
22
- }
23
- ]
18
+ "start": 0,
19
+ "end": 4
24
20
  }
25
21
  ]
26
22
  }
@@ -20,14 +20,10 @@
20
20
  "volume": 0,
21
21
  "width": 640,
22
22
  "height": 480,
23
- "actions": [
24
- {
25
- "start": 0,
26
- "end": 6,
27
- "startFrom": 2,
28
- "endAt": 8
29
- }
30
- ]
23
+ "start": 0,
24
+ "end": 6,
25
+ "startFrom": 2,
26
+ "endAt": 8
31
27
  }
32
28
  ]
33
29
  },
@@ -41,12 +37,8 @@
41
37
  "type": "image",
42
38
  "src": "https://picsum.photos/seed/video-series-photo/640/480",
43
39
  "fit": "cover",
44
- "actions": [
45
- {
46
- "start": 0,
47
- "end": 3
48
- }
49
- ]
40
+ "start": 0,
41
+ "end": 3
50
42
  }
51
43
  ]
52
44
  }
@@ -635,6 +635,24 @@ describe("edge-cases fixture", () => {
635
635
  );
636
636
  expect(compiled.type).toBe("root");
637
637
  });
638
+
639
+ it("parses script code fence for longer text", () => {
640
+ const parsed = parseMarkdownDescriptive(fx.source);
641
+ const fenceScene = parsed.children.find((s: any) => s.name === "ScriptCodeFence");
642
+ expect(fenceScene).toBeDefined();
643
+ // First child: script with code fence content
644
+ expect(fenceScene!.children[0].type).toBe("audio");
645
+ expect(fenceScene!.children[0].script).toContain("longer narration");
646
+ expect(fenceScene!.children[0].script).toContain("multiple lines");
647
+ expect(fenceScene!.children[0].script).toContain("code fence");
648
+ // Second child: regular inline script
649
+ expect(fenceScene!.children[1].type).toBe("audio");
650
+ expect(fenceScene!.children[1].script).toBe("Inline text also works");
651
+
652
+ // Compile should succeed
653
+ const compiled = compileDescriptiveRoot(parsed);
654
+ expect(compiled.type).toBe("root");
655
+ });
638
656
  });
639
657
 
640
658
  // ── Full Feature ──────────────────────────────────────────────────────────
@@ -727,6 +745,158 @@ describe("component-imports fixture", () => {
727
745
  });
728
746
  });
729
747
 
748
+ // ── Dialogue fixture ──────────────────────────────────────────────────────
749
+ // Tests multi-turn dialogue expansion in script fields
750
+
751
+ describe("dialogue fixture", () => {
752
+ const fx = loadFixture("dialogue");
753
+
754
+ it("parses dialogue scenes with script field", () => {
755
+ const parsed = parseMarkdownDescriptive(fx.source);
756
+ const convScene = parsed.children.find((s: any) => s.name === "Conversation");
757
+ expect(convScene).toBeDefined();
758
+ expect(convScene.layout).toBe("series");
759
+ // The dialogue script is a single script node with multi-line content
760
+ const scriptNode = convScene.children.find((c: any) => c.type === "audio" && c.script);
761
+ expect(scriptNode).toBeDefined();
762
+ expect(scriptNode.script).toContain("Ray:");
763
+ expect(scriptNode.script).toContain("Alice:");
764
+ });
765
+
766
+ it("parses single-line script (not dialogue)", () => {
767
+ const parsed = parseMarkdownDescriptive(fx.source);
768
+ const singleScene = parsed.children.find((s: any) => s.name === "SingleLine");
769
+ expect(singleScene).toBeDefined();
770
+ const scriptNode = singleScene.children.find((c: any) => c.type === "audio" && c.script);
771
+ expect(scriptNode).toBeDefined();
772
+ expect(scriptNode.script).toBe("This is just a single line of narration, not dialogue");
773
+ });
774
+ });
775
+
776
+ // ── resolveDialogue unit tests ─────────────────────────────────────────────
777
+
778
+ import { resolveDialogue, parseDialogueLines } from "../src/descriptive/resolve";
779
+
780
+ describe("parseDialogueLines", () => {
781
+ it("detects multi-turn dialogue", () => {
782
+ const lines = parseDialogueLines("Ray: Hello\nAlice: Good day\nRay: Welcome");
783
+ expect(lines).toHaveLength(3);
784
+ expect(lines[0]).toEqual({ speaker: "Ray", text: "Hello" });
785
+ expect(lines[1]).toEqual({ speaker: "Alice", text: "Good day" });
786
+ expect(lines[2]).toEqual({ speaker: "Ray", text: "Welcome" });
787
+ });
788
+
789
+ it("returns empty array for single line", () => {
790
+ expect(parseDialogueLines("Just a single line")).toEqual([]);
791
+ expect(parseDialogueLines("Ray: Hello")).toEqual([]);
792
+ });
793
+
794
+ it("returns empty array for non-matching format", () => {
795
+ expect(parseDialogueLines("not a dialogue line")).toEqual([]);
796
+ expect(parseDialogueLines("No colon here")).toEqual([]);
797
+ });
798
+
799
+ it("handles multi-word speaker names", () => {
800
+ const lines = parseDialogueLines("Dr Smith: Hello\nMs Johnson: Hi there");
801
+ expect(lines).toHaveLength(2);
802
+ expect(lines[0]).toEqual({ speaker: "Dr Smith", text: "Hello" });
803
+ expect(lines[1]).toEqual({ speaker: "Ms Johnson", text: "Hi there" });
804
+ });
805
+
806
+ it("handles Chinese speaker names", () => {
807
+ const lines = parseDialogueLines("小王: 你好\n小李: 早上好");
808
+ expect(lines).toHaveLength(2);
809
+ expect(lines[0]).toEqual({ speaker: "小王", text: "你好" });
810
+ expect(lines[1]).toEqual({ speaker: "小李", text: "早上好" });
811
+ });
812
+
813
+ it("trims whitespace", () => {
814
+ const lines = parseDialogueLines(" Ray: Hello \n Alice: Hi ");
815
+ expect(lines).toHaveLength(2);
816
+ expect(lines[0]).toEqual({ speaker: "Ray", text: "Hello" });
817
+ expect(lines[1]).toEqual({ speaker: "Alice", text: "Hi" });
818
+ });
819
+
820
+ it("requires at least 2 matching lines", () => {
821
+ expect(parseDialogueLines("Ray: One\nNot a dialogue line")).toEqual([]);
822
+ });
823
+ });
824
+
825
+ describe("resolveDialogue", () => {
826
+ it("expands dialogue script into sequential audio nodes wrapped in series", () => {
827
+ const root = {
828
+ children: [
829
+ {
830
+ type: "scene",
831
+ name: "Test",
832
+ children: [
833
+ {
834
+ type: "audio",
835
+ script: "Ray: Hello\nAlice: Good day",
836
+ volume: 1,
837
+ },
838
+ ],
839
+ },
840
+ ],
841
+ } as any;
842
+
843
+ const result = resolveDialogue(root);
844
+
845
+ const scene = result.children[0];
846
+ // Original audio node should be replaced with a series container
847
+ expect(scene.children[0].type).toBe("series");
848
+ expect(scene.children[0].children).toHaveLength(2);
849
+ expect(scene.children[0].children[0].script).toBe("Hello");
850
+ expect(scene.children[0].children[0].speaker).toBe("Ray");
851
+ expect(scene.children[0].children[1].script).toBe("Good day");
852
+ expect(scene.children[0].children[1].speaker).toBe("Alice");
853
+ });
854
+
855
+ it("does not modify single-line scripts", () => {
856
+ const root = {
857
+ children: [
858
+ {
859
+ type: "scene",
860
+ children: [
861
+ { type: "audio", script: "Just a single line", volume: 1 },
862
+ ],
863
+ },
864
+ ],
865
+ } as any;
866
+
867
+ const result = resolveDialogue(root);
868
+ expect(result.children[0].children).toHaveLength(1);
869
+ expect(result.children[0].children[0].type).toBe("audio");
870
+ expect(result.children[0].children[0].script).toBe("Just a single line");
871
+ });
872
+
873
+ it("wraps dialogue lines in series without tts/voices", () => {
874
+ const root = {
875
+ tts: "edge-tts --voice default --text {input} --write-media {output}",
876
+ voices: { Ray: "--voice en-US-GuyNeural", Alice: "--voice en-US-JennyNeural" },
877
+ children: [
878
+ {
879
+ type: "scene",
880
+ children: [
881
+ {
882
+ type: "audio",
883
+ script: "Ray: Hello\nAlice: Good day",
884
+ volume: 1,
885
+ },
886
+ ],
887
+ },
888
+ ],
889
+ } as any;
890
+
891
+ const result = resolveDialogue(root);
892
+ const series = result.children[0].children[0];
893
+ expect(series.tts).toBeUndefined();
894
+ expect(series.voices).toBeUndefined();
895
+ expect(series.type).toBe("series");
896
+ expect(series.children).toHaveLength(2);
897
+ });
898
+ });
899
+
730
900
  // ── Helper ────────────────────────────────────────────────────────────────
731
901
 
732
902
  function findComponents(node: any): any[] {
@@ -492,14 +492,16 @@ describe("Multiple Aspect Ratios", () => {
492
492
  type: "image",
493
493
  src: "https://picsum.photos/seed/aspect-test/1920/1080",
494
494
  fit: "cover",
495
- actions: [{ start: 0, end: 2 }],
495
+ start: 0,
496
+ end: 2,
496
497
  },
497
498
  {
498
499
  id: "title",
499
500
  type: "image",
500
501
  src: "https://picsum.photos/seed/aspect-title/1920/1080",
501
502
  fit: "cover",
502
- actions: [{ start: 0.3, end: 1.7 }],
503
+ start: 0.3,
504
+ end: 1.7,
503
505
  },
504
506
  ],
505
507
  },
@@ -547,21 +549,24 @@ describe("Cross-Stream Type Compatibility", () => {
547
549
  src: "assets/bgm/ambient-loop.mp3",
548
550
  volume: 0.2,
549
551
  isBackground: true,
550
- actions: [{ start: 0, end: 3 }],
552
+ start: 0,
553
+ end: 3,
551
554
  },
552
555
  {
553
556
  id: "img-test",
554
557
  type: "image",
555
558
  src: "https://picsum.photos/seed/cross1/640/480",
556
559
  fit: "cover",
557
- actions: [{ start: 0, end: 3 }],
560
+ start: 0,
561
+ end: 3,
558
562
  },
559
563
  {
560
564
  id: "sub-test",
561
565
  type: "image",
562
566
  src: "https://picsum.photos/seed/cross-sub/640/480",
563
567
  fit: "cover",
564
- actions: [{ start: 0.5, end: 2.5 }],
568
+ start: 0.5,
569
+ end: 2.5,
565
570
  },
566
571
  ],
567
572
  },
@@ -609,10 +614,12 @@ describe("Cross-Stream Type Compatibility", () => {
609
614
  type: "image",
610
615
  src: "https://picsum.photos/seed/eff-child/640/480",
611
616
  fit: "cover",
612
- actions: [{ start: 0, end: 2 }],
617
+ start: 0,
618
+ end: 2,
613
619
  },
614
620
  ],
615
- actions: [{ start: 0, end: 2 }],
621
+ start: 0,
622
+ end: 2,
616
623
  },
617
624
  ],
618
625
  },
@@ -688,7 +695,8 @@ describe("Instruction Field", () => {
688
695
  type: "image",
689
696
  src: "https://picsum.photos/seed/desc/640/480",
690
697
  instruction: "A beautiful sunset",
691
- actions: [{ start: 0, end: 3 }],
698
+ start: 0,
699
+ end: 3,
692
700
  });
693
701
  expect(withInst.instruction).toBe("A beautiful sunset");
694
702
 
@@ -696,7 +704,8 @@ describe("Instruction Field", () => {
696
704
  id: "test-img-2",
697
705
  type: "image",
698
706
  src: "https://picsum.photos/seed/no-desc/640/480",
699
- actions: [{ start: 0, end: 3 }],
707
+ start: 0,
708
+ end: 3,
700
709
  });
701
710
  expect(withoutInst.instruction).toBeUndefined();
702
711
  });
@@ -709,7 +718,8 @@ describe("Instruction Field", () => {
709
718
  type: "video",
710
719
  src: "test.mp4",
711
720
  instruction: "label for video clip",
712
- actions: [{ start: 0, end: 5 }],
721
+ start: 0,
722
+ end: 5,
713
723
  });
714
724
  expect(v.instruction).toBe("label for video clip");
715
725
 
@@ -718,7 +728,8 @@ describe("Instruction Field", () => {
718
728
  type: "component",
719
729
  jsx: "<StatCounter value={100} />",
720
730
  instruction: "stats component label",
721
- actions: [{ start: 0, end: 3 }],
731
+ start: 0,
732
+ end: 3,
722
733
  });
723
734
  expect(c.instruction).toBe("stats component label");
724
735
  });
@@ -740,7 +751,8 @@ describe("Instruction Field", () => {
740
751
  src: "https://picsum.photos/seed/desc-render/640/480",
741
752
  instruction: "Preserved instruction",
742
753
  fit: "cover",
743
- actions: [{ start: 0, end: 2 }],
754
+ start: 0,
755
+ end: 2,
744
756
  },
745
757
  ],
746
758
  },
@@ -793,7 +805,8 @@ describe("Scene as Folder Alias", () => {
793
805
  type: "image",
794
806
  src: "https://picsum.photos/seed/scene-alias-1/640/480",
795
807
  fit: "cover",
796
- actions: [{ start: 0, end: 2 }],
808
+ start: 0,
809
+ end: 2,
797
810
  },
798
811
  ],
799
812
  },
@@ -808,7 +821,8 @@ describe("Scene as Folder Alias", () => {
808
821
  type: "image",
809
822
  src: "https://picsum.photos/seed/scene-alias-2/640/480",
810
823
  fit: "cover",
811
- actions: [{ start: 0, end: 2 }],
824
+ start: 0,
825
+ end: 2,
812
826
  },
813
827
  ],
814
828
  },
@@ -865,7 +879,8 @@ describe("Scene as Folder Alias", () => {
865
879
  type: "image",
866
880
  src: "https://picsum.photos/seed/mix-scene/640/480",
867
881
  fit: "cover",
868
- actions: [{ start: 0, end: 2 }],
882
+ start: 0,
883
+ end: 2,
869
884
  },
870
885
  ],
871
886
  },
@@ -879,7 +894,8 @@ describe("Scene as Folder Alias", () => {
879
894
  type: "image",
880
895
  src: "https://picsum.photos/seed/mix-folder/640/480",
881
896
  fit: "cover",
882
- actions: [{ start: 0, end: 2 }],
897
+ start: 0,
898
+ end: 2,
883
899
  },
884
900
  ],
885
901
  },
@@ -41,7 +41,8 @@ describe("component node", () => {
41
41
  const c = component.parse({
42
42
  type: "component",
43
43
  jsx: "<BarChart data={[{value: 1}]} />",
44
- actions: [{ start: 0, end: 2 }],
44
+ start: 0,
45
+ end: 2,
45
46
  });
46
47
  expect(c.type).toBe("component");
47
48
  expect(c.jsx).toContain("BarChart");
@@ -52,7 +53,8 @@ describe("component node", () => {
52
53
  type: "component",
53
54
  jsx: "<Hello name={who} />",
54
55
  data: { who: "World" },
55
- actions: [{ start: 0, end: 1 }],
56
+ start: 0,
57
+ end: 1,
56
58
  });
57
59
  expect(c.data).toEqual({ who: "World" });
58
60
  });
@@ -61,7 +63,8 @@ describe("component node", () => {
61
63
  expect(() =>
62
64
  component.parse({
63
65
  type: "component",
64
- actions: [{ start: 0, end: 1 }],
66
+ start: 0,
67
+ end: 1,
65
68
  }),
66
69
  ).toThrow();
67
70
  });
@@ -48,7 +48,8 @@ function makeFixture(name: string): string {
48
48
  type: "component",
49
49
  id: `comp-${name}`,
50
50
  jsx: `<div>${name}</div>`,
51
- actions: [{ start: 0, end: 3 }],
51
+ start: 0,
52
+ end: 3,
52
53
  },
53
54
  ],
54
55
  },
@@ -274,16 +275,18 @@ describe("HTML page variant injection", () => {
274
275
  }
275
276
  });
276
277
 
277
- it("shows variant switcher bar with all variant links", async () => {
278
- const fixture = makeFixture("html-bar");
278
+ it("sets mode and variant in globals for client-side rendering", async () => {
279
+ const fixture = makeFixture("html-mode");
279
280
  const port = nextPort();
280
281
  const stop = await startServer(fixture, ["default", "zh-tiktok", "youtube"], port);
281
282
  try {
282
283
  const res = await fetchUrl(`http://localhost:${port}/`);
283
284
  expect(res.status).toBe(200);
284
- expect(res.text).toContain('href="/"');
285
- expect(res.text).toContain('href="/zh-tiktok"');
286
- expect(res.text).toContain('href="/youtube"');
285
+ // Variant bar is now rendered client-side by React (VariantBar.tsx).
286
+ // Server only provides the config globals and loads player.js.
287
+ expect(res.text).toContain('window.VARIANT = "default"');
288
+ expect(res.text).toContain('window.MODE = "preview"');
289
+ expect(res.text).toContain('src="/player.js"');
287
290
  } finally {
288
291
  stop();
289
292
  }
package/tests/utils.ts CHANGED
@@ -376,10 +376,10 @@ export function getExpectedDuration(fixtureData: any): number {
376
376
  }
377
377
  }
378
378
 
379
- // Leaf action duration
380
- if (fixtureData.actions?.length) {
381
- return Math.max(...fixtureData.actions.map((a: any) => a.end ?? 1));
382
- }
379
+ // Leaf duration from base timing fields (end is source of truth)
380
+ const leafStart = fixtureData.start ?? 0;
381
+ if (typeof fixtureData.end === "number") return fixtureData.end;
382
+ if (typeof fixtureData.duration === "number") return leafStart + fixtureData.duration;
383
383
 
384
384
  return 1;
385
385
  }