@lalalic/markcut 1.1.1 → 2.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.
Files changed (69) hide show
  1. package/AGENTS.md +39 -15
  2. package/README.md +7 -3
  3. package/SKILL.md +26 -172
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +46 -1
  7. package/docs/system-prompt-edit.md +16 -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/entry.tsx +8 -2
  16. package/src/player/browser.tsx +178 -54
  17. package/src/player/bundle/player.js +1168 -566
  18. package/src/player/components/EditControls.tsx +92 -0
  19. package/src/player/components/HeaderBar.tsx +60 -0
  20. package/src/player/components/LabelControls.tsx +367 -0
  21. package/src/player/components/SceneThumbnails.tsx +87 -0
  22. package/src/player/components/VariantBar.tsx +39 -0
  23. package/src/player/components/index.ts +5 -0
  24. package/src/player/pipeline.mjs +130 -66
  25. package/src/player/pipeline.ts +3 -1
  26. package/src/player/server-shared.mjs +5 -7
  27. package/src/player/server.mjs +194 -187
  28. package/src/render/cli.mjs +66 -13
  29. package/src/schema/index.ts +20 -23
  30. package/src/types/Audio.tsx +25 -33
  31. package/src/types/Component.tsx +18 -24
  32. package/src/types/Effect.tsx +31 -39
  33. package/src/types/Image.tsx +23 -30
  34. package/src/types/Include.tsx +70 -76
  35. package/src/types/Map.tsx +48 -44
  36. package/src/types/Rhythm.tsx +19 -27
  37. package/src/types/Video.tsx +40 -47
  38. package/src/utils/component-import-map.ts +93 -0
  39. package/src/utils/index.ts +23 -10
  40. package/src/vision/cli.mjs +6 -6
  41. package/templates/courseware/TEMPLATE.md +317 -0
  42. package/templates/courseware/agents/reviewer.md +84 -0
  43. package/templates/courseware/prompts/fix.md +30 -0
  44. package/templates/courseware/prompts/outline.md +53 -0
  45. package/templates/courseware/prompts/scene.md +64 -0
  46. package/tests/fixtures/audio.json +4 -2
  47. package/tests/fixtures/basic.json +2 -1
  48. package/tests/fixtures/component-all.json +4 -2
  49. package/tests/fixtures/components.json +4 -2
  50. package/tests/fixtures/effects.json +12 -6
  51. package/tests/fixtures/full.json +8 -4
  52. package/tests/fixtures/map.json +2 -1
  53. package/tests/fixtures/md/dialogue.md +12 -0
  54. package/tests/fixtures/md/edge-cases.md +9 -0
  55. package/tests/fixtures/scenes.json +4 -2
  56. package/tests/fixtures/subtitle.json +6 -3
  57. package/tests/fixtures/subvideo.json +11 -6
  58. package/tests/fixtures/templates/courseware.md +61 -4
  59. package/tests/fixtures/tween-visual.json +2 -6
  60. package/tests/fixtures/video-series.json +6 -14
  61. package/tests/md-descriptive.test.ts +170 -0
  62. package/tests/render.test.ts +32 -16
  63. package/tests/schema.test.ts +6 -3
  64. package/tests/server.test.ts +9 -6
  65. package/tests/utils.ts +4 -4
  66. package/docs/dynamic-components.md +0 -191
  67. package/docs/templates.md +0 -52
  68. package/scripts/artlist-dl.mjs +0 -190
  69. package/src/player/label-server.mjs +0 -599
@@ -11,7 +11,8 @@
11
11
  "type": "audio",
12
12
  "src": "assets/bgm/ambient-loop.mp3",
13
13
  "volume": 0.15,
14
- "actions": [{ "start": 0, "end": 5 }]
14
+ "start": 0,
15
+ "end": 5
15
16
  },
16
17
  {
17
18
  "id": "scene-1",
@@ -23,13 +24,15 @@
23
24
  "type": "component",
24
25
  "jsx": "<div style={{position:'absolute',inset:0,background:'radial-gradient(circle at center,#667eea,#764ba2)'}} />",
25
26
  "isBackground": true,
26
- "actions": [{ "start": 0, "end": 5 }]
27
+ "start": 0,
28
+ "end": 5
27
29
  },
28
30
  {
29
31
  "id": "headline",
30
32
  "type": "component",
31
33
  "jsx": "<div style={{background:'linear-gradient(135deg,#667eea,#764ba2)',padding:'40px',borderRadius:'16px',textAlign:'center'}}><h1 style={{color:'white',fontSize:'32px',margin:0}}>Full Feature Test</h1><p style={{color:'rgba(255,255,255,0.7)',fontSize:'16px'}}>All systems operational</p></div>",
32
- "actions": [{ "start": 0.3, "end": 4.5 }]
34
+ "start": 0.3,
35
+ "end": 4.5
33
36
  }
34
37
  ]
35
38
  },
@@ -43,7 +46,8 @@
43
46
  "type": "component",
44
47
  "type": "component",
45
48
  "jsx": "<div style={{background:'#1a1a2e',padding:'30px',borderRadius:'16px',textAlign:'center'}}><span style={{color:'#667eea',fontSize:'64px',fontWeight:'bold'}}>100%</span><p style={{color:'rgba(255,255,255,0.6)',fontSize:'16px'}}>Coverage</p></div>",
46
- "actions": [{ "start": 0.5, "end": 4.5 }]
49
+ "start": 0.5,
50
+ "end": 4.5
47
51
  }
48
52
  ]
49
53
  }
@@ -17,7 +17,8 @@
17
17
  ],
18
18
  "routeColor": "#4285F4",
19
19
  "routeWeight": 5,
20
- "actions": [{ "start": 0, "end": 3 }]
20
+ "start": 0,
21
+ "end": 3
21
22
  }
22
23
  ]
23
24
  }
@@ -0,0 +1,12 @@
1
+ # video
2
+ width:640 height:480 fps:30
3
+
4
+ ## Conversation
5
+ layout:series
6
+ - script "Ray: Hello everyone and welcome to the show
7
+ Alice: Good day to you all
8
+ Ray: Today we're going to talk about dialogue support"
9
+
10
+ ## SingleLine
11
+ layout:series
12
+ - script "This is just a single line of narration, not dialogue"
@@ -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
  });