@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.
- package/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- 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
|
+
}
|