@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.
- package/AGENTS.md +39 -17
- package/README.md +7 -3
- package/SKILL.md +0 -2
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +34 -1
- package/docs/system-prompt-edit.md +16 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -3
- package/src/descriptive/compiler.test.ts +42 -42
- package/src/descriptive/compiler.ts +41 -52
- package/src/descriptive/markdown.ts +8 -4
- package/src/descriptive/resolve.test.ts +14 -7
- package/src/descriptive/resolve.ts +148 -20
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1168 -566
- package/src/player/components/EditControls.tsx +92 -0
- package/src/player/components/HeaderBar.tsx +60 -0
- package/src/player/components/LabelControls.tsx +367 -0
- package/src/player/components/SceneThumbnails.tsx +87 -0
- package/src/player/components/VariantBar.tsx +39 -0
- package/src/player/components/index.ts +5 -0
- package/src/player/pipeline.mjs +130 -66
- package/src/player/pipeline.ts +3 -1
- package/src/player/server-shared.mjs +5 -7
- package/src/player/server.mjs +194 -187
- package/src/render/cli.mjs +4 -6
- package/src/schema/index.ts +20 -23
- package/src/types/Audio.tsx +25 -33
- package/src/types/Component.tsx +18 -24
- package/src/types/Effect.tsx +31 -39
- package/src/types/Image.tsx +23 -30
- package/src/types/Include.tsx +70 -76
- package/src/types/Map.tsx +48 -44
- package/src/types/Rhythm.tsx +19 -27
- package/src/types/Video.tsx +40 -47
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +16 -10
- package/tests/fixtures/audio.json +4 -2
- package/tests/fixtures/basic.json +2 -1
- package/tests/fixtures/component-all.json +4 -2
- package/tests/fixtures/components.json +4 -2
- package/tests/fixtures/effects.json +12 -6
- package/tests/fixtures/full.json +8 -4
- package/tests/fixtures/map.json +2 -1
- package/tests/fixtures/md/dialogue.md +12 -0
- package/tests/fixtures/md/edge-cases.md +9 -0
- package/tests/fixtures/scenes.json +4 -2
- package/tests/fixtures/subtitle.json +6 -3
- package/tests/fixtures/subvideo.json +11 -6
- package/tests/fixtures/templates/courseware.md +61 -4
- package/tests/fixtures/tween-visual.json +2 -6
- package/tests/fixtures/video-series.json +6 -14
- package/tests/md-descriptive.test.ts +170 -0
- package/tests/render.test.ts +32 -16
- package/tests/schema.test.ts +6 -3
- package/tests/server.test.ts +9 -6
- package/tests/utils.ts +4 -4
- package/scripts/artlist-dl.mjs +0 -190
- 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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
package/src/types/Rhythm.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/types/Video.tsx
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
-
const
|
|
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
|
|
126
|
+
// leaf with base timing fields (start/end/duration)
|
|
113
127
|
if (!stream.children?.length) {
|
|
114
|
-
const
|
|
115
|
-
const d = last?.end ?? 0;
|
|
128
|
+
const d = leafEnd(stream);
|
|
116
129
|
if (update) stream.durationInSeconds = d;
|
|
117
130
|
return d;
|
|
118
131
|
}
|
package/src/vision/cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
947
|
-
if (!existsSync(
|
|
948
|
-
emitError(`
|
|
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", [
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
23
|
+
"start": 0,
|
|
24
|
+
"end": 3
|
|
23
25
|
}
|
|
24
26
|
]
|
|
25
27
|
}
|
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
23
|
+
"start": 0,
|
|
24
|
+
"end": 2
|
|
24
25
|
}
|
|
25
26
|
],
|
|
26
|
-
"
|
|
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
|
-
"
|
|
40
|
+
"start": 0,
|
|
41
|
+
"end": 2
|
|
39
42
|
}
|
|
40
43
|
],
|
|
41
|
-
"
|
|
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
|
-
"
|
|
62
|
+
"start": 0,
|
|
63
|
+
"end": 2
|
|
59
64
|
}
|
|
60
65
|
],
|
|
61
|
-
"
|
|
66
|
+
"start": 0,
|
|
67
|
+
"end": 2
|
|
62
68
|
}
|
|
63
69
|
]
|
|
64
70
|
}
|
package/tests/fixtures/full.json
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"type": "audio",
|
|
12
12
|
"src": "assets/bgm/ambient-loop.mp3",
|
|
13
13
|
"volume": 0.15,
|
|
14
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
49
|
+
"start": 0.5,
|
|
50
|
+
"end": 4.5
|
|
47
51
|
}
|
|
48
52
|
]
|
|
49
53
|
}
|
package/tests/fixtures/map.json
CHANGED
|
@@ -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"
|