@lalalic/markcut 1.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 (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Map stream type — animated route on Google Maps.
3
+ *
4
+ * Renders a Google Map with Directions API route between waypoints and an
5
+ * animated marker that travels along the path in sync with the current frame.
6
+ * Uses @vis.gl/react-google-maps (Google Maps JS API wrapper) — no separate
7
+ * API key management needed beyond what's embedded in the engine build.
8
+ *
9
+ * Adapted from qili-ai studio's map component.
10
+ *
11
+ * Usage in stream tree:
12
+ * {
13
+ * type: "map",
14
+ * waypoints: [{ lat, lng, label? }],
15
+ * travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
16
+ * mapType: "roadmap", // roadmap | satellite | hybrid | terrain
17
+ * routeMarker: "🚗", // emoji/char for animated pin
18
+ * actions: [{ start: 0, end: 5 }]
19
+ * }
20
+ */
21
+ import React from "react";
22
+ import { Sequence, useCurrentFrame, useVideoConfig, delayRender, continueRender } from "remotion";
23
+ import { useFrameEvents } from "../context/index";
24
+ import {
25
+ APIProvider, Map as GoogleMap, useMap, useMapsLibrary,
26
+ AdvancedMarker, Pin,
27
+ } from "@vis.gl/react-google-maps";
28
+ import type { MapStream } from "../schema/index";
29
+
30
+ const GM_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
31
+
32
+ // ============================================================
33
+ // MapLeaf — entry point, renders each action as a Sequence
34
+ // ============================================================
35
+ export function MapLeaf({ stream }: { stream: MapStream }) {
36
+ const { fps } = useVideoConfig();
37
+ const waypoints = stream.waypoints ?? [];
38
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
39
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
40
+ if (waypoints.length === 0) return null;
41
+
42
+ 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
+ </>
84
+ );
85
+ }
86
+
87
+ // ============================================================
88
+ // RouteWithMarker — gets the route via DirectionsService, then
89
+ // renders an animated marker that follows the route path
90
+ // ============================================================
91
+ function RouteWithMarker({
92
+ waypoints, travelMode, markerEmoji, actionDuration,
93
+ }: {
94
+ waypoints: { lat: number; lng: number; label?: string }[];
95
+ travelMode: string;
96
+ markerEmoji: string;
97
+ actionDuration: number;
98
+ }) {
99
+ const map = useMap();
100
+ const routesLibrary = useMapsLibrary("routes");
101
+ const [leg, setLeg] = React.useState<google.maps.DirectionsLeg | null>(null);
102
+ const [routeIndex, setRouteIndex] = React.useState(0);
103
+ const handle = React.useRef<number | null>(null);
104
+
105
+ // Load directions
106
+ React.useEffect(() => {
107
+ if (!routesLibrary || !map || waypoints.length < 2) return;
108
+ const renderHandle = delayRender("Loading map directions...");
109
+ handle.current = renderHandle;
110
+
111
+ const renderer = new routesLibrary.DirectionsRenderer({ map, suppressMarkers: true });
112
+ const service = new routesLibrary.DirectionsService();
113
+
114
+ service
115
+ .route({
116
+ origin: waypoints[0],
117
+ destination: waypoints[waypoints.length - 1],
118
+ waypoints: waypoints.slice(1, -1).map((wp) => ({ location: wp, stopover: true })),
119
+ travelMode: google.maps.TravelMode[travelMode as keyof typeof google.maps.TravelMode],
120
+ provideRouteAlternatives: false,
121
+ })
122
+ .then((response) => {
123
+ renderer.setDirections(response);
124
+ setRouteIndex(0);
125
+ setLeg(response.routes[0]?.legs[0] ?? null);
126
+ if (handle.current !== null) continueRender(handle.current);
127
+ })
128
+ .catch(() => {
129
+ if (handle.current !== null) continueRender(handle.current);
130
+ });
131
+
132
+ return () => {
133
+ renderer.setMap(null);
134
+ };
135
+ }, [routesLibrary, map, waypoints, travelMode]);
136
+
137
+ // Update route index
138
+ React.useEffect(() => {
139
+ setRouteIndex((prev) => prev);
140
+ }, [routeIndex]);
141
+
142
+ // Compute animated marker position
143
+ const position = useAnimatedPosition({ leg, actionDuration, waypoints });
144
+
145
+ return (
146
+ <>
147
+ {waypoints.map((wp, i) => (
148
+ <AdvancedMarker key={i} position={wp}>
149
+ {wp.label ? (
150
+ <div
151
+ style={{
152
+ background: "rgba(255,255,255,0.9)",
153
+ borderRadius: "4px",
154
+ padding: "2px 6px",
155
+ fontSize: "12px",
156
+ fontWeight: 700,
157
+ color: "#333",
158
+ whiteSpace: "nowrap",
159
+ position: "relative",
160
+ top: "-24px",
161
+ }}
162
+ >
163
+ {wp.label}
164
+ </div>
165
+ ) : null}
166
+ </AdvancedMarker>
167
+ ))}
168
+ {position ? (
169
+ <AdvancedMarker position={position}>
170
+ <Pin glyphText={markerEmoji} scale={4} />
171
+ </AdvancedMarker>
172
+ ) : null}
173
+ </>
174
+ );
175
+ }
176
+
177
+ // ============================================================
178
+ // useAnimatedPosition — returns the current lat/lng of the
179
+ // animated marker along the route path
180
+ // ============================================================
181
+ function useAnimatedPosition({
182
+ leg, actionDuration, waypoints,
183
+ }: {
184
+ leg: google.maps.DirectionsLeg | null;
185
+ actionDuration: number;
186
+ waypoints: { lat: number; lng: number }[];
187
+ }) {
188
+ const frame = useCurrentFrame();
189
+ const { fps } = useVideoConfig();
190
+
191
+ return React.useMemo(() => {
192
+ if (!leg || !leg.duration?.value) {
193
+ // Fallback: linear interpolation between waypoints
194
+ if (waypoints.length < 2) return null;
195
+ const t = Math.min(frame / (actionDuration * fps), 1);
196
+ const total = waypoints.length - 1;
197
+ const segI = Math.min(Math.floor(t * total), total - 1);
198
+ const segT = (t * total) - segI;
199
+ const a = waypoints[segI];
200
+ const b = waypoints[segI + 1];
201
+ if (!a || !b) return null;
202
+ return {
203
+ lat: a.lat + (b.lat - a.lat) * segT,
204
+ lng: a.lng + (b.lng - a.lng) * segT,
205
+ };
206
+ }
207
+
208
+ // Follow the route path using leg steps
209
+ const currentInSecond = (frame / fps) * (leg.duration.value / actionDuration);
210
+ const { step, elapsedInSeconds } = getCurrentStep(leg, currentInSecond);
211
+ if (!step || !step.path) {
212
+ // Fallback to linear
213
+ const t = Math.min(frame / (actionDuration * fps), 1);
214
+ const total = waypoints.length - 1;
215
+ const segI = Math.min(Math.floor(t * total), total - 1);
216
+ const segT = (t * total) - segI;
217
+ const a = waypoints[segI];
218
+ const b = waypoints[segI + 1];
219
+ if (!a || !b) return null;
220
+ return {
221
+ lat: a.lat + (b.lat - a.lat) * segT,
222
+ lng: a.lng + (b.lng - a.lng) * segT,
223
+ };
224
+ }
225
+
226
+ const stepElapsed = currentInSecond - elapsedInSeconds;
227
+ const stepProgress = stepElapsed / (step.duration?.value ?? 1);
228
+ const pathIdx = Math.min(
229
+ Math.max(0, Math.floor(stepProgress * step.path.length)),
230
+ step.path.length - 1,
231
+ );
232
+ const pt = step.path[pathIdx];
233
+ if (!pt) return null;
234
+ return { lat: pt.lat(), lng: pt.lng() };
235
+ }, [leg, frame, fps, actionDuration, waypoints]);
236
+ }
237
+
238
+ // ============================================================
239
+ // getCurrentStep — finds which step of a DirectionsLeg the
240
+ // current time falls into
241
+ // ============================================================
242
+ function getCurrentStep(leg: google.maps.DirectionsLeg, currentInSecond: number) {
243
+ let elapsedInSeconds = 0;
244
+ for (const step of leg.steps ?? []) {
245
+ const stepDur = step.duration?.value ?? 0;
246
+ if (elapsedInSeconds <= currentInSecond && currentInSecond < elapsedInSeconds + stepDur) {
247
+ return { step, elapsedInSeconds };
248
+ }
249
+ elapsedInSeconds += stepDur;
250
+ }
251
+ return { step: null, elapsedInSeconds: 0 };
252
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Rhythm stream type — audio playback with loop + beat-synced children.
3
+ *
4
+ * The rhythm stream plays audio in a loop. Children are expected to have
5
+ * their timing pre-computed (by a build step or CLI tool) to align with
6
+ * detected beat positions stored in `spots[]`.
7
+ *
8
+ * Usage in stream tree:
9
+ * { type: "rhythm", src: "beat.mp3", spots: [0.5, 1.2, 1.9], children: [...] }
10
+ */
11
+ import * as React from "react";
12
+ import { Sequence, Audio as RemotionAudio, useRemotionEnvironment, useVideoConfig, staticFile } from "remotion";
13
+ import { useFrameEvents } from "../context/index";
14
+ import type { Rhythm } from "../schema/index";
15
+
16
+ function resolveAudioSrc(src: string): string {
17
+ if (/^(https?:|data:|blob:|file:|\/)/.test(src)) return src;
18
+ return staticFile(src);
19
+ }
20
+
21
+ export function RhythmLeaf({ stream }: { stream: Rhythm }) {
22
+ const { fps } = useVideoConfig();
23
+ const environment = useRemotionEnvironment();
24
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
25
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
26
+
27
+ if (!stream.src || environment.isStudio) return null;
28
+
29
+ const resolvedSrc = resolveAudioSrc(stream.src);
30
+
31
+ 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
+ </>
57
+ );
58
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * SceneLeaf — renders a descriptive container node for storyboarding.
3
+ *
4
+ * A scene is purely a visual grouping node with metadata (name, description)
5
+ * that renders its children like a folder. It exists so UI tools can display
6
+ * high-level storyboard cards without needing to parse the full stream tree.
7
+ *
8
+ * The engine treats it identically to a folder at render time.
9
+ */
10
+ import * as React from "react";
11
+ import { Folder } from "remotion"
12
+ import { ComposeContext, AudioContext, useFrameEvents } from "../context/index";
13
+ import { cssJS, toClassName } from "../utils/index";
14
+ import type { Scene as SceneStream } from "../schema/index";
15
+ import { FolderLeaf } from "./Folder";
16
+
17
+ export function SceneLeaf({ stream }: { stream: SceneStream }) {
18
+ const { Container } = React.useContext(ComposeContext);
19
+ const parentAudio = React.useContext(AudioContext);
20
+ const totalDur = stream.durationInSeconds ?? 1;
21
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * 30)));
22
+
23
+ const audioCtx = React.useMemo(
24
+ () => ({ id: stream.id, parent: parentAudio }),
25
+ [stream.id, parentAudio],
26
+ );
27
+
28
+ return (
29
+ <AudioContext.Provider value={audioCtx as any}>
30
+ <Container
31
+ id={stream.id}
32
+ type="scene"
33
+ style={cssJS(stream.style) as React.CSSProperties}
34
+ className={`scene ${toClassName(stream.name ?? "")}`}
35
+ >
36
+ <Folder name={stream.name ?? ""}>
37
+ <FolderLeaf stream={stream as any} />
38
+ </Folder>
39
+ </Container>
40
+ </AudioContext.Provider>
41
+ );
42
+ }
@@ -0,0 +1,218 @@
1
+ import * as React from "react";
2
+ import { cancelRender, continueRender, delayRender, Sequence, staticFile, useVideoConfig } from "remotion";
3
+ import * as Subtitles from "remotion-subtitle";
4
+ import { cssJS, parseVTT, type Cue } from "../utils/index";
5
+ import type { SubtitleOverlay as SubtitleOverlayConfig } from "../schema/index";
6
+
7
+ const { SubtitleSequence: _SubtitleSeq, ...CaptionComponents } = Subtitles;
8
+
9
+ function resolveSubtitleSrc(src: string): string {
10
+ if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:") || src.startsWith("/")) {
11
+ return src;
12
+ }
13
+ return staticFile(src);
14
+ }
15
+
16
+ const DEFAULT_BOX_STYLE: React.CSSProperties = {
17
+ position: "absolute",
18
+ inset: 0,
19
+ display: "flex",
20
+ alignItems: "flex-end",
21
+ justifyContent: "center",
22
+ padding: "0 5% 8% 5%",
23
+ pointerEvents: "none",
24
+ zIndex: 100,
25
+ };
26
+
27
+ const DEFAULT_TEXT_STYLE: React.CSSProperties = {
28
+ color: "white",
29
+ fontSize: 56,
30
+ fontWeight: 700,
31
+ textAlign: "center",
32
+ textShadow: "0 2px 12px rgba(0,0,0,0.85)",
33
+ lineHeight: 1.2,
34
+ fontFamily: '"PingFang SC","Noto Sans CJK SC","Hiragino Sans","Helvetica Neue",sans-serif',
35
+ };
36
+
37
+ /**
38
+ * Resolve the caption component from a type string.
39
+ * "typewriter" → TypewriterCaption, "fade" → FadeCaption, etc.
40
+ * Falls back to base Caption component when type is omitted or unknown.
41
+ */
42
+ function resolveCaption(type?: string): React.FC<{ text: any; style?: React.CSSProperties }> {
43
+ if (!type) return CaptionComponents.Caption;
44
+ const key = type.charAt(0).toUpperCase() + type.slice(1) + "Caption" as keyof typeof CaptionComponents;
45
+ return (CaptionComponents[key] ?? CaptionComponents.Caption) as React.FC<{ text: any; style?: React.CSSProperties }>;
46
+ }
47
+
48
+ /**
49
+ * Wraps text so that it renders HTML via dangerouslySetInnerHTML while
50
+ * still being compatible with caption components that call .length,
51
+ * .slice(), or .split() on the text prop (e.g. TypewriterCaption).
52
+ *
53
+ * The returned object is a React element (span with innerHTML) augmented
54
+ * with string-like methods so animation components can iterate over
55
+ * characters while respecting HTML tag boundaries.
56
+ */
57
+ function supportHtml(text: string): any {
58
+ const el = { ...<span dangerouslySetInnerHTML={{ __html: text }} /> };
59
+ Object.assign(el, {
60
+ length: text.length,
61
+ slice(start: number, end: number) {
62
+ const chars: string[] = [];
63
+ let inTag = false;
64
+ let counter = 0;
65
+ for (const char of text) {
66
+ if (inTag) {
67
+ chars.push(char);
68
+ if (char === ">") inTag = false;
69
+ continue;
70
+ }
71
+ if (char === "<") {
72
+ chars.push(char);
73
+ inTag = true;
74
+ } else if (char === ">") {
75
+ chars.push(char);
76
+ inTag = false;
77
+ } else {
78
+ if (counter <= end) chars.push(char);
79
+ counter++;
80
+ }
81
+ }
82
+ return <span dangerouslySetInnerHTML={{ __html: chars.join("") }} />;
83
+ },
84
+ split(separator: string | RegExp, limit?: number) {
85
+ const stripped = text.replace(/<.*?>/g, "");
86
+ return stripped.split(separator, limit);
87
+ },
88
+ });
89
+ return el;
90
+ }
91
+
92
+ /**
93
+ * Renders a single cue inside a <Sequence> so Remotion only evaluates it
94
+ * during the cue's active time window — matching the qili-ai pattern.
95
+ * This avoids per-frame cue lookups and lets the caption component mount
96
+ * once per cue rather than re-creating on every frame.
97
+ */
98
+ function CueFrame({
99
+ cue,
100
+ fps,
101
+ CaptionComponent,
102
+ subtitle,
103
+ }: {
104
+ cue: Cue;
105
+ fps: number;
106
+ CaptionComponent: React.FC<{ text: any; style?: React.CSSProperties }>;
107
+ subtitle: SubtitleOverlayConfig;
108
+ }) {
109
+ const durationInFrames = Math.max(1, Math.floor((cue.endAt - cue.startFrom) * fps));
110
+ const from = Math.floor(cue.startFrom * fps);
111
+
112
+ // Stable reference: compute once per cue, not every frame
113
+ const captionText = React.useMemo(() => supportHtml(cue.text), [cue.text]);
114
+ const textStyle: React.CSSProperties = React.useMemo(
115
+ () => ({
116
+ ...DEFAULT_TEXT_STYLE,
117
+ fontSize: subtitle.fontSize ?? DEFAULT_TEXT_STYLE.fontSize,
118
+ fontFamily: subtitle.fontFamily ?? DEFAULT_TEXT_STYLE.fontFamily,
119
+ fontStyle: subtitle.fontStyle as any,
120
+ }),
121
+ [subtitle.fontSize, subtitle.fontFamily, subtitle.fontStyle],
122
+ );
123
+
124
+ return (
125
+ <Sequence layout="none" durationInFrames={durationInFrames} from={from}>
126
+ <CaptionComponent text={captionText} style={textStyle} />
127
+ </Sequence>
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Root-level subtitle overlay. Renders a single VTT file as captions over
133
+ * the entire video. VTT cue timestamps are absolute (seconds from video start),
134
+ * matching useCurrentFrame() / fps directly.
135
+ *
136
+ * Each cue is rendered as a separate <Sequence> (qili-ai pattern) so Remotion
137
+ * only mounts/evaluates the caption component during its active time window.
138
+ * This avoids per-frame computation for inactive cues.
139
+ *
140
+ * Supports a `type` field to select the caption animation component:
141
+ * "bounce", "fade", "typewriter", "colorful", "glowing", "neon", etc.
142
+ * Defaults to plain `Caption` when type is omitted.
143
+ *
144
+ * Mounted once inside AbsoluteFill in MarkCut when root.subtitle is set.
145
+ */
146
+ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig }) {
147
+ const { fps } = useVideoConfig();
148
+ const [cues, setCues] = React.useState<Cue[] | null>(null);
149
+
150
+ const CaptionComponent = React.useMemo(
151
+ () => resolveCaption(subtitle.type),
152
+ [subtitle.type],
153
+ );
154
+
155
+ React.useEffect(() => {
156
+ const { src } = subtitle;
157
+
158
+ // Inline VTT body
159
+ if (src.includes("-->")) {
160
+ setCues(parseVTT(src));
161
+ return;
162
+ }
163
+
164
+ if (!/\.vtt(?:$|[?#])/.test(src)) {
165
+ // Plain text — single static caption for the whole video
166
+ setCues([{ startFrom: 0, endAt: Infinity, text: src }]);
167
+ return;
168
+ }
169
+
170
+ const handle = delayRender(`Loading subtitles: ${src}`);
171
+ let active = true;
172
+ let settled = false;
173
+ const finish = () => {
174
+ if (settled) return;
175
+ settled = true;
176
+ continueRender(handle);
177
+ };
178
+
179
+ fetch(resolveSubtitleSrc(src))
180
+ .then((r) => {
181
+ if (!r.ok) throw new Error(`Failed to load subtitles (${r.status}): ${src}`);
182
+ return r.text();
183
+ })
184
+ .then((text) => {
185
+ if (!active) return;
186
+ setCues(parseVTT(text));
187
+ finish();
188
+ })
189
+ .catch((err) => {
190
+ if (!active) return;
191
+ settled = true;
192
+ cancelRender(err);
193
+ });
194
+
195
+ return () => {
196
+ active = false;
197
+ finish();
198
+ };
199
+ }, [subtitle.src]);
200
+
201
+ if (!cues) return null;
202
+
203
+ const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
204
+
205
+ return (
206
+ <div className={`${subtitle.type || "default"} subtitle-overlay`} style={{ ...DEFAULT_BOX_STYLE, ...boxCss }}>
207
+ {cues.map((cue, i) => (
208
+ <CueFrame
209
+ key={`${i}-${cue.startFrom}-${cue.endAt}`}
210
+ cue={cue}
211
+ fps={fps}
212
+ CaptionComponent={CaptionComponent}
213
+ subtitle={subtitle}
214
+ />
215
+ ))}
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,70 @@
1
+ import * as React from "react";
2
+ import { Sequence, OffthreadVideo, useVideoConfig, staticFile } from "remotion";
3
+
4
+ function resolveVideoSrc(src: string): string {
5
+ if (/^(https?:|data:|blob:|file:|\/)/.test(src)) return src;
6
+ return staticFile(src);
7
+ }
8
+ import { ComposeContext, AudioContext, useFrameEvents } from "../context/index";
9
+ import { toPlaybackRate, cssJS } from "../utils/index";
10
+ import type { Video } from "../schema/index";
11
+ import { FrameSyncStyle } from "./FrameSyncStyle";
12
+
13
+ export function VideoLeaf({ stream }: { stream: Video }) {
14
+ const { fps } = useVideoConfig();
15
+ const audio = React.useContext(AudioContext);
16
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
17
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
18
+ if (!stream.src) return null;
19
+ const resolvedSrc = resolveVideoSrc(stream.src);
20
+
21
+ 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"
38
+ 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
+ </>
69
+ );
70
+ }