@lalalic/markcut 1.2.0 → 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 (61) hide show
  1. package/AGENTS.md +39 -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 +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/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1168 -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 +87 -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 +194 -187
  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
package/src/types/Map.tsx CHANGED
@@ -15,7 +15,7 @@
15
15
  * travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
16
16
  * mapType: "roadmap", // roadmap | satellite | hybrid | terrain
17
17
  * routeMarker: "🚗", // emoji/char for animated pin
18
- * actions: [{ start: 0, end: 5 }]
18
+ * start: 0, end: 5
19
19
  * }
20
20
  */
21
21
  import React from "react";
@@ -27,7 +27,17 @@ import {
27
27
  } from "@vis.gl/react-google-maps";
28
28
  import type { MapStream } from "../schema/index";
29
29
 
30
- const GM_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
30
+ // API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
31
+ // This fallback handles the case where Map.tsx is used directly without the compiler.
32
+ function resolveApiKey(stream: MapStream): string {
33
+ if (stream.googleMapsApiKey) return stream.googleMapsApiKey;
34
+ // Safe fallback for Node.js contexts (e.g. remotion render without compiler).
35
+ // In the browser player the key is always pre-stamped by the server-side compiler.
36
+ if (typeof process !== "undefined" && typeof process.env !== "undefined" && process.env.GOOGLE_MAPS_API_KEY) {
37
+ return process.env.GOOGLE_MAPS_API_KEY;
38
+ }
39
+ return "";
40
+ }
31
41
 
32
42
  // ============================================================
33
43
  // MapLeaf — entry point, renders each action as a Sequence
@@ -35,52 +45,46 @@ const GM_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
35
45
  export function MapLeaf({ stream }: { stream: MapStream }) {
36
46
  const { fps } = useVideoConfig();
37
47
  const waypoints = stream.waypoints ?? [];
38
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
48
+ const start = stream.start ?? 0;
49
+ const end = stream.end ?? start + (stream.duration ?? 1);
50
+ const totalDur = stream.durationInSeconds ?? end;
51
+ const apiKey = resolveApiKey(stream);
39
52
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
40
53
  if (waypoints.length === 0) return null;
41
54
 
55
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
56
+ const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
57
+ const zoom = stream.zoom ?? 10;
58
+ const mapType = stream.mapType ?? "roadmap";
59
+ const travelMode = stream.travelMode ?? "DRIVING";
60
+ const markerEmoji = stream.routeMarker ?? "🚗";
42
61
  return (
43
- <>
44
- {stream.actions?.map((a, i) => {
45
- const start = a.start ?? 0;
46
- const end = a.end ?? start + 1;
47
- const durFrames = Math.max(1, Math.floor(fps * (end - start)));
48
- const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
49
- const zoom = stream.zoom ?? 10;
50
- const mapType = stream.mapType ?? "roadmap";
51
- const travelMode = stream.travelMode ?? "DRIVING";
52
- const markerEmoji = stream.routeMarker ?? "🚗";
53
- return (
54
- <Sequence
55
- key={a.id ?? i}
56
- durationInFrames={durFrames}
57
- from={Math.floor(fps * start)}
58
- layout="none"
59
- >
60
- <APIProvider apiKey={GM_API_KEY}>
61
- <GoogleMap
62
- mapId={String(stream.id ?? i)}
63
- defaultCenter={center}
64
- defaultZoom={zoom}
65
- defaultOptions={{
66
- mapTypeId: mapType,
67
- disableDefaultUI: true,
68
- zoomControl: false,
69
- }}
70
- style={{ width: "100%", height: "100%", position: "absolute" }}
71
- >
72
- <RouteWithMarker
73
- waypoints={waypoints}
74
- travelMode={travelMode}
75
- markerEmoji={markerEmoji}
76
- actionDuration={end - start}
77
- />
78
- </GoogleMap>
79
- </APIProvider>
80
- </Sequence>
81
- );
82
- })}
83
- </>
62
+ <Sequence
63
+ durationInFrames={durFrames}
64
+ from={Math.floor(fps * start)}
65
+ layout="none"
66
+ >
67
+ <APIProvider apiKey={apiKey}>
68
+ <GoogleMap
69
+ mapId={String(stream.id ?? "map")}
70
+ defaultCenter={center}
71
+ defaultZoom={zoom}
72
+ defaultOptions={{
73
+ mapTypeId: mapType,
74
+ disableDefaultUI: true,
75
+ zoomControl: false,
76
+ }}
77
+ style={{ width: "100%", height: "100%", position: "absolute" }}
78
+ >
79
+ <RouteWithMarker
80
+ waypoints={waypoints}
81
+ travelMode={travelMode}
82
+ markerEmoji={markerEmoji}
83
+ actionDuration={end - start}
84
+ />
85
+ </GoogleMap>
86
+ </APIProvider>
87
+ </Sequence>
84
88
  );
85
89
  }
86
90
 
@@ -21,38 +21,30 @@ function resolveAudioSrc(src: string): string {
21
21
  export function RhythmLeaf({ stream }: { stream: Rhythm }) {
22
22
  const { fps } = useVideoConfig();
23
23
  const environment = useRemotionEnvironment();
24
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
24
+ const start = stream.start ?? 0;
25
+ const end = stream.end ?? start + (stream.duration ?? 1);
26
+ const totalDur = stream.durationInSeconds ?? end;
25
27
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
26
28
 
27
29
  if (!stream.src || environment.isStudio) return null;
28
30
 
29
31
  const resolvedSrc = resolveAudioSrc(stream.src);
30
-
32
+ const volume = stream.volume ?? 1;
31
33
  return (
32
- <>
33
- {stream.actions.map((a) => {
34
- const start = a.start ?? 0;
35
- const end = a.end ?? start + 1;
36
- const volume = a.volume ?? stream.volume ?? 1;
37
- return (
38
- <Sequence
39
- key={a.id}
40
- name={stream.src ?? "rhythm"}
41
- durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
42
- from={Math.floor(fps * start)}
43
- layout="none"
44
- showInTimeline={false}
45
- >
46
- <RemotionAudio
47
- src={resolvedSrc}
48
- muted={volume === 0}
49
- volume={volume}
50
- loop
51
- showInTimeline={false}
52
- />
53
- </Sequence>
54
- );
55
- })}
56
- </>
34
+ <Sequence
35
+ name={stream.src ?? "rhythm"}
36
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
37
+ from={Math.floor(fps * start)}
38
+ layout="none"
39
+ showInTimeline={false}
40
+ >
41
+ <RemotionAudio
42
+ src={resolvedSrc}
43
+ muted={volume === 0}
44
+ volume={volume}
45
+ loop
46
+ showInTimeline={false}
47
+ />
48
+ </Sequence>
57
49
  );
58
50
  }
@@ -13,58 +13,51 @@ import { FrameSyncStyle } from "./FrameSyncStyle";
13
13
  export function VideoLeaf({ stream }: { stream: Video }) {
14
14
  const { fps } = useVideoConfig();
15
15
  const audio = React.useContext(AudioContext);
16
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
16
+ const start = stream.start ?? 0;
17
+ const end = stream.end ?? start + (stream.duration ?? 1);
18
+ const totalDur = stream.durationInSeconds ?? end;
17
19
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
18
20
  if (!stream.src) return null;
19
21
  const resolvedSrc = resolveVideoSrc(stream.src);
20
22
 
23
+ const startFrom = stream.startFrom ?? 0;
24
+ const endAt = stream.endAt ?? totalDur;
25
+ const volume = stream.volume ?? 1;
26
+ const playbackRate = stream.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
27
+ const streamStyle = cssJS(stream.style);
28
+ const hasAnimation = "animation" in streamStyle;
21
29
  return (
22
- <>
23
- {stream.actions.map((a) => {
24
- const start = a.start ?? 0;
25
- const end = a.end ?? start + 1;
26
- const startFrom = a.startFrom ?? 0;
27
- const endAt = a.endAt ?? stream.durationInSeconds ?? end - start;
28
- const volume = a.volume ?? stream.volume ?? 1;
29
- const playbackRate = a.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
30
- const actionStyle = cssJS(a.style);
31
- const hasAnimation = "animation" in actionStyle;
32
- return (
33
- <Sequence
34
- key={a.id}
35
- durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
36
- from={Math.floor(fps * start)}
37
- layout="none"
30
+ <Sequence
31
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
32
+ from={Math.floor(fps * start)}
33
+ layout="none"
34
+ showInTimeline={false}
35
+ >
36
+ {hasAnimation ? (
37
+ <FrameSyncStyle style={streamStyle}>
38
+ <OffthreadVideo
39
+ src={resolvedSrc}
40
+ startFrom={Math.floor(startFrom * fps)}
41
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
42
+ muted={volume === 0 || !!audio?.foreground}
43
+ volume={volume}
44
+ playbackRate={playbackRate}
38
45
  showInTimeline={false}
39
- >
40
- {hasAnimation ? (
41
- <FrameSyncStyle style={actionStyle}>
42
- <OffthreadVideo
43
- src={resolvedSrc}
44
- startFrom={Math.floor(startFrom * fps)}
45
- endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
46
- muted={volume === 0 || !!audio?.foreground}
47
- volume={volume}
48
- playbackRate={playbackRate}
49
- showInTimeline={false}
50
- style={{ width: "100%", height: "100%" }}
51
- />
52
- </FrameSyncStyle>
53
- ) : (
54
- <OffthreadVideo
55
- src={resolvedSrc}
56
- startFrom={Math.floor(startFrom * fps)}
57
- endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
58
- muted={volume === 0 || !!audio?.foreground}
59
- volume={volume}
60
- playbackRate={playbackRate}
61
- showInTimeline={false}
62
- style={{ width: "100%", height: "100%", ...actionStyle }}
63
- />
64
- )}
65
- </Sequence>
66
- );
67
- })}
68
- </>
46
+ style={{ width: "100%", height: "100%" }}
47
+ />
48
+ </FrameSyncStyle>
49
+ ) : (
50
+ <OffthreadVideo
51
+ src={resolvedSrc}
52
+ startFrom={Math.floor(startFrom * fps)}
53
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
54
+ muted={volume === 0 || !!audio?.foreground}
55
+ volume={volume}
56
+ playbackRate={playbackRate}
57
+ showInTimeline={false}
58
+ style={{ width: "100%", height: "100%", ...streamStyle }}
59
+ />
60
+ )}
61
+ </Sequence>
69
62
  );
70
63
  }
@@ -57,11 +57,12 @@ export function walkDown<T extends StreamNode>(
57
57
  * Compute duration of a stream subtree, in seconds.
58
58
  *
59
59
  * - rhythm streams use their pre-set durationInSeconds (set by host)
60
- * - leaf actions use action.end as default
60
+ * - leaf duration uses base `end` (or `start + duration`) via leafEnd()
61
61
  * - series sums children, subtracting transition overlaps
62
62
  * - sequence (parallel) takes max child duration
63
63
  * - background children do not contribute
64
- * - actions[].streamRef is unsupported in lite (no template instancing)
64
+ * - leaf duration uses base `end` (or `start + duration`) via leafEnd()
65
+ * - streamRef/template instancing is unsupported in lite
65
66
  */
66
67
  export interface DurationStream extends StreamNode {
67
68
  type?: string;
@@ -70,10 +71,24 @@ export interface DurationStream extends StreamNode {
70
71
  transition?: string;
71
72
  transitionTime?: number;
72
73
  durationInSeconds?: number;
73
- actions?: Array<{ start?: number; end?: number; startFrom?: number; endAt?: number }>;
74
+ start?: number;
75
+ end?: number;
76
+ duration?: number;
74
77
  children?: DurationStream[];
75
78
  }
76
79
 
80
+ /**
81
+ * Resolve a leaf node's effective end time from its base timing fields.
82
+ * `end` is the source of truth; `duration` is a convenience that normalizes
83
+ * to `start + duration` when `end` is absent. Returns 0 when neither is set.
84
+ */
85
+ export function leafEnd(stream: { start?: number; end?: number; duration?: number }): number {
86
+ if (typeof stream.end === "number") return stream.end;
87
+ const s = stream.start ?? 0;
88
+ if (typeof stream.duration === "number") return s + stream.duration;
89
+ return 0;
90
+ }
91
+
77
92
  export function getDurationInSeconds(stream: DurationStream, update = true): number {
78
93
  if (!stream) return 0;
79
94
 
@@ -81,13 +96,12 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
81
96
  return stream.durationInSeconds ?? 0;
82
97
  }
83
98
 
84
- // include: if src is set, treat as leaf (duration from action end).
99
+ // include: if src is set, treat as leaf (duration from base end).
85
100
  // Otherwise fall back to inline children (legacy).
86
101
  if (stream.type === "include") {
87
102
  if (stream.src) {
88
- // External reference — use action end (like other leaves)
89
- const last = stream.actions?.[stream.actions.length - 1];
90
- const d = last?.end ?? 0;
103
+ // External reference — use base end (like other leaves)
104
+ const d = leafEnd(stream);
91
105
  if (update) stream.durationInSeconds = d;
92
106
  return d;
93
107
  }
@@ -109,10 +123,9 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
109
123
  }
110
124
 
111
125
  // scene is a container (like folder) — falls through to general children logic
112
- // leaf with actions
126
+ // leaf with base timing fields (start/end/duration)
113
127
  if (!stream.children?.length) {
114
- const last = stream.actions?.[stream.actions.length - 1];
115
- const d = last?.end ?? 0;
128
+ const d = leafEnd(stream);
116
129
  if (update) stream.durationInSeconds = d;
117
130
  return d;
118
131
  }
@@ -662,7 +662,7 @@ function buildPreviewTree(folder, metadata) {
662
662
  id: `${e.name}-media`,
663
663
  type: e.isVideo ? "video" : "image",
664
664
  src: e.relSrc, fit: "cover",
665
- actions: [{ start: 0, end: e.dur }],
665
+ start: 0, end: e.dur,
666
666
  // Preserve existing labels from metadata so re-labeling shows them
667
667
  userHints: e.userHints || undefined,
668
668
  }],
@@ -943,9 +943,9 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
943
943
  const previewJsonPath = join(folder, ".preview.json");
944
944
  writeFileSync(previewJsonPath, JSON.stringify(previewTree, null, 2), "utf-8");
945
945
 
946
- const labelServer = join(__dirname, "..", "player", "label-server.mjs");
947
- if (!existsSync(labelServer)) {
948
- emitError(`Label server not found at ${labelServer}`);
946
+ const playerServer = join(__dirname, "..", "player", "server.mjs");
947
+ if (!existsSync(playerServer)) {
948
+ emitError(`Player server not found at ${playerServer}`);
949
949
  process.exit(1);
950
950
  }
951
951
 
@@ -954,7 +954,7 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
954
954
  emitInfo(` Labels will be merged into metadata.json as user hints.\n`);
955
955
 
956
956
  const port = 3031;
957
- const child = spawn("node", [labelServer, previewJsonPath, `--port=${port}`], {
957
+ const child = spawn("node", [playerServer, previewJsonPath, "--label", `--port=${port}`], {
958
958
  cwd: resolve(__dirname, "..", ".."),
959
959
  stdio: ["ignore", "pipe", "inherit"],
960
960
  });
@@ -963,7 +963,7 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
963
963
  if (child.stdout) {
964
964
  child.stdout.on("data", (chunk) => {
965
965
  process.stdout.write(chunk);
966
- if (!serverReady && chunk.toString().includes("Label Preview")) {
966
+ if (!serverReady && chunk.toString().includes("Player ready")) {
967
967
  serverReady = true;
968
968
  try { execSync(`open http://localhost:${port}`, { stdio: "ignore" }); } catch {}
969
969
  }
@@ -2,7 +2,7 @@
2
2
  name: courseware
3
3
  description: Turn a topic or teaching material into a professional slide-based course video with TTS narration, bullet-reveal slides, and a reviewer quality gate.
4
4
  when-to-use: lessons, lectures, training, tutorials, explainers — 1-5 minute educational videos built from slides + narration
5
- engine: "@lalalic/markcut — run via `npx @lalalic/markcut`"
5
+ test:
6
6
  ---
7
7
 
8
8
  # Courseware Template
@@ -21,7 +21,6 @@ Layout of this template package:
21
21
 
22
22
  - markcut skill loaded (engine contract: `SKILL.md` + `docs/markdown-descriptive.md`)
23
23
  - `npx @lalalic/markcut` runnable
24
- - TTS CLI available (default: `edge-tts`)
25
24
  - For the reviewer gate: `ffmpeg`/`ffprobe`, an STT CLI (e.g. whisper), and image-understanding capability
26
25
 
27
26
  ## 1. Inputs — collect before starting
@@ -32,7 +31,6 @@ Layout of this template package:
32
31
  | Audience level | no | beginner | beginner / intermediate / expert |
33
32
  | Language(s) | no | en | extra languages become variants (see §3) |
34
33
  | Target duration | no | 2 min | 1–5 min |
35
- | Voice per language | no | en: `en-US-GuyNeural`, zh: `zh-CN-YunxiNeural` | edge-tts voice names |
36
34
  | Accent color | no | `#61dafb` | theme knob, see §4 |
37
35
 
38
36
  **Rule:** if the topic/material is missing or ambiguous, ask the user. Never invent course content beyond the given material plus well-established knowledge of the topic. Never invent facts, numbers, or citations.
@@ -82,6 +80,13 @@ Scene rules:
82
80
 
83
81
  - **Duration is TTS-driven**: never set explicit `duration` on scenes that have a script. Only fixed-visual scenes (Hook, Thanks) get `duration:`.
84
82
 
83
+ ### make expressful slide
84
+ - use chart to explain when making sense
85
+ - group script and images/videos to make slide more expressful
86
+ - select different transition
87
+ - apply effects
88
+ - control tts with emotion,breaks, speed for natural speech
89
+
85
90
  ## 3. Authoring rules — the professional bar
86
91
 
87
92
  Slide content:
@@ -94,7 +99,7 @@ Slide content:
94
99
 
95
100
  Narration script:
96
101
 
97
- - **One short paragraph per bullet.** Never merge bullets into a single monolithic script. Each bullet gets its own `- script "..."` node with `on:(start, id.current=N)`.
102
+ - **One paragraph per bullet.** Never merge bullets into a single monolithic script. Each bullet gets its own `- script "..."` node with `on:(start, id.current=N)`.
98
103
  - Paragraph length ≈ **15–30 words** per beat (≈6–12 seconds at 2.5 w/s). Total words = target minutes × 150.
99
104
  - Each paragraph **expands** that bullet — never reads it verbatim. The paragraph plus all sibling paragraphs must touch every bullet on the slide.
100
105
  - The concrete example gets its own bullet and its own script paragraph. Never bury the example in a generic paragraph.
@@ -122,6 +127,7 @@ Copy both blocks into the generated `course.md` unchanged (except the theme knob
122
127
  import ReactMarkdown from 'react-markdown';
123
128
  import remarkGfm from 'remark-gfm'
124
129
  import mermaid from 'mermaid'
130
+ import React from 'react'
125
131
  import { delayRender, continueRender } from 'remotion'
126
132
 
127
133
  // Initialize mermaid once. Theme matches the slide deck's dark scheme.
@@ -149,12 +155,7 @@ export function Mermaid({ source }) {
149
155
  })
150
156
  }, [source])
151
157
 
152
- return (
153
- <div
154
- ref={ref}
155
- style={{ width: '100%', maxWidth: 960, margin: '20px auto' }}
156
- />
157
- )
158
+ return (<divref={ref}style={{ width: '100%', maxWidth: 960, margin: '20px auto' }}/> )
158
159
  }
159
160
 
160
161
  /**
@@ -305,6 +306,11 @@ Done only when ALL hold:
305
306
  - [ ] structure matches §2 (hook, title, 3–6 concepts, summary, thanks)
306
307
  - [ ] no blank/black frames at scene boundaries; slides legible at target resolution
307
308
  - [ ] STT transcript of rendered audio matches scripts (≥90% content match)
309
+ - [ ] each bullet should be narrated
310
+ - [ ] expressful slides applied
311
+ - [ ] 80% slides have charts/image/...
312
+
313
+
308
314
 
309
315
  ## 7. Reference — worked example
310
316
 
@@ -12,14 +12,16 @@
12
12
  "src": "assets/bgm/ambient-loop.mp3",
13
13
  "volume": 0.3,
14
14
  "isBackground": true,
15
- "actions": [{ "start": 0, "end": 3 }]
15
+ "start": 0,
16
+ "end": 3
16
17
  },
17
18
  {
18
19
  "id": "bg",
19
20
  "type": "image",
20
21
  "src": "https://picsum.photos/seed/audio-test/640/480",
21
22
  "fit": "cover",
22
- "actions": [{ "start": 0, "end": 3 }]
23
+ "start": 0,
24
+ "end": 3
23
25
  }
24
26
  ]
25
27
  }
@@ -19,7 +19,8 @@
19
19
  "type": "image",
20
20
  "src": "https://picsum.photos/seed/test-basic/640/480",
21
21
  "fit": "cover",
22
- "actions": [{ "start": 0, "end": 2 }]
22
+ "start": 0,
23
+ "end": 2
23
24
  }
24
25
  ]
25
26
  }
@@ -16,7 +16,8 @@
16
16
  "id": "c-hello",
17
17
  "type": "component",
18
18
  "jsx": "<div style={{color:'#fff',fontSize:28,textAlign:'center'}}>Hello World</div>",
19
- "actions": [{ "start": 0, "end": 2 }]
19
+ "start": 0,
20
+ "end": 2
20
21
  }
21
22
  ]
22
23
  },
@@ -30,7 +31,8 @@
30
31
  "id": "c-tween",
31
32
  "type": "component",
32
33
  "jsx": "<svg width=\"200\" height=\"200\" viewBox=\"0 0 200 200\"><rect x=\"0\" y={tween(0,150)} width=\"50\" height={tween(50,200)} fill=\"#667eea\" /><rect x=\"70\" y={tween(50,100)} width=\"50\" height={tween(100,50)} fill=\"#764ba2\" /><rect x=\"140\" y={tween(100,0)} width=\"50\" height={tween(50,200)} fill=\"#f093fb\" /></svg>",
33
- "actions": [{ "start": 0, "end": 3 }]
34
+ "start": 0,
35
+ "end": 3
34
36
  }
35
37
  ]
36
38
  }
@@ -17,7 +17,8 @@
17
17
  "id": "headline",
18
18
  "type": "component",
19
19
  "jsx": "<div style={{background:'linear-gradient(135deg,#667eea,#764ba2)',padding:'40px',borderRadius:'16px',textAlign:'center'}}><h1 style={{color:'white',fontSize:'36px',margin:0}}>Component Test</h1><p style={{color:'rgba(255,255,255,0.7)',fontSize:'18px'}}>Built-in components render correctly</p></div>",
20
- "actions": [{ "start": 0, "end": 3 }]
20
+ "start": 0,
21
+ "end": 3
21
22
  }
22
23
  ]
23
24
  },
@@ -30,7 +31,8 @@
30
31
  "id": "stats",
31
32
  "type": "component",
32
33
  "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'}}>Working</p></div>",
33
- "actions": [{ "start": 0, "end": 3 }]
34
+ "start": 0,
35
+ "end": 3
34
36
  }
35
37
  ]
36
38
  }
@@ -20,10 +20,12 @@
20
20
  "type": "image",
21
21
  "src": "https://picsum.photos/seed/effect-fade/640/480",
22
22
  "fit": "cover",
23
- "actions": [{ "start": 0, "end": 2 }]
23
+ "start": 0,
24
+ "end": 2
24
25
  }
25
26
  ],
26
- "actions": [{ "start": 0, "end": 2 }]
27
+ "start": 0,
28
+ "end": 2
27
29
  },
28
30
  {
29
31
  "id": "bounce",
@@ -35,10 +37,12 @@
35
37
  "type": "image",
36
38
  "src": "https://picsum.photos/seed/effect-bounce/640/480",
37
39
  "fit": "cover",
38
- "actions": [{ "start": 0, "end": 2 }]
40
+ "start": 0,
41
+ "end": 2
39
42
  }
40
43
  ],
41
- "actions": [{ "start": 0, "end": 2 }]
44
+ "start": 0,
45
+ "end": 2
42
46
  },
43
47
  {
44
48
  "id": "custom-kf",
@@ -55,10 +59,12 @@
55
59
  "type": "image",
56
60
  "src": "https://picsum.photos/seed/effect-custom/640/480",
57
61
  "fit": "cover",
58
- "actions": [{ "start": 0, "end": 2 }]
62
+ "start": 0,
63
+ "end": 2
59
64
  }
60
65
  ],
61
- "actions": [{ "start": 0, "end": 2 }]
66
+ "start": 0,
67
+ "end": 2
62
68
  }
63
69
  ]
64
70
  }
@@ -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"