@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,259 @@
1
+ import { z } from "zod";
2
+ import { uid } from "../utils/index";
3
+
4
+ /**
5
+ * Lite stream schema.
6
+ * Render-only — no prompts, no async transforms, no providers.
7
+ *
8
+ * A stream tree is rooted at `root`. Folders contain children.
9
+ * Leaf streams (video/audio/image/component) have actions[]
10
+ * describing when they appear on the timeline.
11
+ *
12
+ * Subtitles are a root-level overlay: root.subtitle points to a VTT file
13
+ * that covers the full video duration. Generated by the TTS pipeline.
14
+ */
15
+
16
+ export const action = z.object({
17
+ id: z.string().default(() => uid()),
18
+ start: z.number().min(0).default(0).describe("seconds, relative to parent"),
19
+ end: z.number().min(0).default(1).describe("seconds, relative to parent"),
20
+ startFrom: z.number().optional().describe("trim seconds from source start"),
21
+ endAt: z.number().optional().describe("trim seconds at source end"),
22
+ loop: z.number().int().min(1).optional().describe(">1 = loop count"),
23
+ effectId: z.string().optional(),
24
+ style: z.string().optional().describe("inline css"),
25
+ volume: z.number().min(0).max(1).optional(),
26
+ });
27
+ export type Action = z.infer<typeof action>;
28
+
29
+ export const eventSpec = z.object({
30
+ when: z.string().describe("'start' | 'end' | '50%' | '2.5s'"),
31
+ state: z.string().describe("JS expression evaluated with registered components in scope"),
32
+ });
33
+ export type EventSpec = z.infer<typeof eventSpec>;
34
+
35
+ const BaseShape = {
36
+ id: z.string().default(() => uid()),
37
+ instruction: z.string().optional().describe("visual intent or style guide for this node (not rendered)"),
38
+ src: z.string().optional(),
39
+ style: z.string().optional().describe("inline css"),
40
+ visible: z.boolean().default(true),
41
+ isBackground: z.boolean().optional(),
42
+ durationInSeconds: z.number().optional().describe("set by engine; do not edit by hand"),
43
+ on: eventSpec.optional().describe("event that fires at a specific frame, mutating registered component state"),
44
+ };
45
+
46
+ export const base = z.object(BaseShape);
47
+
48
+ export const folder = base.extend({
49
+ type: z.literal("folder").default("folder"),
50
+ isSeries: z.boolean().optional(),
51
+ transition: z.enum(["fade", "slide", "wipe", "flip", "clockWipe"]).optional(),
52
+ transitionTime: z.number().min(0.1).max(5).default(0.5),
53
+ shadow: z.number().min(0).optional(),
54
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
55
+ });
56
+ export type Folder = z.infer<typeof folder>;
57
+
58
+ export const subtitleOverlay = z.object({
59
+ src: z.string().describe("path or URL to VTT file covering the full video duration"),
60
+ style: z.string().optional().describe("inline css for the overlay container"),
61
+ fontSize: z.union([z.number(), z.string()]).optional(),
62
+ type: z.string().optional().describe("caption animation type: bounce, fade, typewriter, colorful, glowing, neon, etc."),
63
+ fontFamily: z.string().optional().describe("font family for subtitle text"),
64
+ fontStyle: z.string().optional().describe("font style: normal, italic, bold, bold italic, etc."),
65
+ });
66
+ export type SubtitleOverlay = z.infer<typeof subtitleOverlay>;
67
+
68
+ export const root = folder.extend({
69
+ id: z.literal("root").default("root"),
70
+ type: z.literal("root").default("root"),
71
+ width: z.number().int().positive().default(1080),
72
+ height: z.number().int().positive().default(1920),
73
+ fps: z.number().int().positive().default(30),
74
+ instruction: z.string().optional(),
75
+ metadata: z.string().optional(),
76
+ stylesheet: z.string().optional().describe("global css; selectors use .type and .name"),
77
+ subtitle: subtitleOverlay.optional().describe("global subtitle overlay; src is a VTT file with absolute timestamps"),
78
+ /**
79
+ * Component registry source for react-jsx-parser.
80
+ *
81
+ * Two runtime forms (the descriptive ImportEntry[] array is resolved by the
82
+ * compiler/server BEFORE reaching here):
83
+ * - string: URL to a pre-bundled ESM module whose named exports are the
84
+ * components (set by the player server after bundling frontmatter
85
+ * `imports:`).
86
+ * - object: inline `{ Name: ComponentType }` map for programmatic use.
87
+ *
88
+ * Without this field declared, zod's default `.strip()` removes it during
89
+ * `root.parse()`, so the registry never loads at runtime.
90
+ */
91
+ imports: z
92
+ .union([z.string(), z.record(z.string(), z.any())])
93
+ .optional()
94
+ .describe("component registry: URL to a pre-bundled module (string) or inline component map (object)"),
95
+ });
96
+ export type Root = z.infer<typeof root>;
97
+
98
+ export const video = base.extend({
99
+ type: z.literal("video").default("video"),
100
+ src: z.string().optional(),
101
+ volume: z.number().min(0).max(1).default(1),
102
+ playbackRate: z.number().optional(),
103
+ width: z.number().default(1080),
104
+ height: z.number().default(1920),
105
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
106
+ });
107
+ export type Video = z.infer<typeof video>;
108
+
109
+ export const audio = base.extend({
110
+ type: z.literal("audio").default("audio"),
111
+ src: z.string().optional(),
112
+ volume: z.number().min(0).max(1).default(1),
113
+ foreground: z.boolean().optional().describe("ducks parent video audio while playing"),
114
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
115
+ });
116
+ export type Audio = z.infer<typeof audio>;
117
+
118
+ export const image = base.extend({
119
+ type: z.literal("image").default("image"),
120
+ src: z.string().optional(),
121
+ fit: z.enum(["contain", "cover", "fill"]).default("contain"),
122
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
123
+ });
124
+ export type Image = z.infer<typeof image>;
125
+
126
+ /**
127
+ * Component node — JSX usage expression compiled at runtime.
128
+ *
129
+ * The `jsx` field is a React JSX expression (e.g. "<BarChart data={...} />").
130
+ * Component tag names are resolved from the `components` map at runtime in compose context.
131
+
132
+ */
133
+ export const component = base.extend({
134
+ type: z.literal("component").default("component"),
135
+ jsx: z.string().describe("usage JSX expression compiled at runtime; tag names resolved from imports"),
136
+ data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe("extra variables (e.g. from ~~~md source code fences) available in JSX scope"),
137
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
138
+ });
139
+ export type Component = z.infer<typeof component>;
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Effect — CSS keyframe animation wrapper
143
+ // ---------------------------------------------------------------------------
144
+ export const effect = base.extend({
145
+ type: z.literal("effect").default("effect"),
146
+ animation: z.string().optional().describe("builtin keyframe name or 'custom'"),
147
+ animationTimingFunction: z
148
+ .enum(["linear", "ease", "ease-in", "ease-out", "ease-in-out"])
149
+ .optional(),
150
+ animationIterationCount: z.number().default(1),
151
+ customKeyframes: z
152
+ .record(z.string(), z.record(z.string(), z.string()))
153
+ .optional()
154
+ .describe('inline keyframes: { "0": { opacity: "0" }, "100": { opacity: "1" } }'),
155
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
156
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
157
+ });
158
+ export type Effect = z.infer<typeof effect>;
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Rhythm — audio loop with beat-synced children
162
+ // ---------------------------------------------------------------------------
163
+ export const rhythm = base.extend({
164
+ type: z.literal("rhythm").default("rhythm"),
165
+ src: z.string().optional().describe("audio file for beat playback"),
166
+ volume: z.number().min(0).max(1).default(1),
167
+ spots: z.array(z.number()).optional().describe("pre-computed beat timestamps in seconds"),
168
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
169
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
170
+ });
171
+ export type Rhythm = z.infer<typeof rhythm>;
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Include — references an external video JSON file by src.
175
+ // The referenced file can be either:
176
+ // - A stream tree JSON (has `type: "root"` or `root` property)
177
+ // - A scene-based video.json (has `meta` and `scenes`)
178
+ //
179
+ // Usage:
180
+ // { type: "include", src: "./path/to/video.json", actions: [{ start: 0, end: 5 }] }
181
+ //
182
+ // Falls back to inline `children` if `src` is not set (legacy behavior).
183
+ // ---------------------------------------------------------------------------
184
+ export const include = base.extend({
185
+ type: z.literal("include").default("include"),
186
+ src: z.string().optional().describe("path or URL to video JSON file (stream tree or scene-based)"),
187
+ volume: z.number().min(0).max(1).default(1),
188
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
189
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
190
+ imports: z.string().optional().describe("component bundle URL for sub-video (set by engine)"),
191
+ });
192
+ export type Include = z.infer<typeof include>;
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Scene — descriptive container node for storyboarding.
196
+ // A scene is a visual grouping node with metadata (name, description) that
197
+ // renders its children like a folder. It exists so UI tools can display
198
+ // high-level storyboard cards without needing to understand the full stream
199
+ // tree. The engine treats it identically to a folder.
200
+ //
201
+ // Usage:
202
+ // { type: "scene", name: "Intro", description: "Opening hook",
203
+ // children: [{ type: "component", componentName: "BigStatement", ... }] }
204
+ //
205
+ // Parent folder's isSeries controls sequencing (same as folder children).
206
+ // ---------------------------------------------------------------------------
207
+ export const scene = base.extend({
208
+ type: z.literal("scene").default("scene"),
209
+ name: z.string().optional().describe("scene name from heading"),
210
+ title: z.string().optional().describe("scene display title"),
211
+ instruction: z.string().optional().describe("scene visual intent for storyboard UI"),
212
+ children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
213
+ durationInSeconds: z.number().optional().describe("set by engine; do not edit by hand"),
214
+ });
215
+ export type Scene = z.infer<typeof scene>;
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Map — animated route visualization
219
+ // ---------------------------------------------------------------------------
220
+ export const mapWaypoint = z.object({
221
+ lat: z.number(),
222
+ lng: z.number(),
223
+ label: z.string().optional(),
224
+ media: z.string().optional().describe("image/video src for waypoint marker"),
225
+ });
226
+
227
+ export const mapStream = base.extend({
228
+ type: z.literal("map").default("map"),
229
+ waypoints: z.array(mapWaypoint).default(() => []),
230
+ routeColor: z.string().default("#4285F4"),
231
+ routeWeight: z.number().default(4),
232
+ zoom: z.number().default(10),
233
+ center: z.object({ lat: z.number(), lng: z.number() }).optional().describe("map view center (defaults to first waypoint)"),
234
+ mapType: z.enum(["roadmap", "satellite", "hybrid", "terrain"]).default("roadmap").describe("Google Maps style"),
235
+ travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
236
+ routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
237
+ actions: z.array(action).min(1).default(() => [action.parse({})]),
238
+ });
239
+ export type MapStream = z.infer<typeof mapStream>;
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Discriminated union
243
+ // ---------------------------------------------------------------------------
244
+ export const stream = z.discriminatedUnion("type", [
245
+ root,
246
+ folder,
247
+ video,
248
+ audio,
249
+ image,
250
+ component,
251
+ effect,
252
+ rhythm,
253
+ mapStream,
254
+ include,
255
+ scene,
256
+ ]);
257
+ export type Stream = z.infer<typeof stream>;
258
+
259
+
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Sequence, Audio as RemotionAudio, useRemotionEnvironment, useVideoConfig, staticFile } from "remotion";
3
+ import { AudioContext, useFrameEvents } from "../context/index";
4
+ import { toPlaybackRate } from "../utils/index";
5
+ import type { Audio } from "../schema/index";
6
+
7
+ function resolveAudioSrc(src: string): string {
8
+ if (/^(https?:|data:|blob:|file:|\/)/.test(src)) return src;
9
+ return staticFile(src);
10
+ }
11
+
12
+ export function AudioLeaf({ stream }: { stream: Audio }) {
13
+ const { fps } = useVideoConfig();
14
+ const environment = useRemotionEnvironment();
15
+ const ctx = 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
+ if (environment.isStudio) return null;
20
+
21
+ const resolvedSrc = resolveAudioSrc(stream.src);
22
+
23
+ return (
24
+ <>
25
+ {stream.actions.map((a) => {
26
+ const start = a.start ?? 0;
27
+ const end = a.end ?? start + 1;
28
+ const startFrom = a.startFrom ?? 0;
29
+ const endAt = a.endAt ?? stream.durationInSeconds ?? end - start;
30
+ const volume = a.volume ?? stream.volume ?? 1;
31
+ const playbackRate = a.loop ? 1 : toPlaybackRate((endAt - startFrom) / (end - start));
32
+ return (
33
+ <Sequence
34
+ key={a.id}
35
+ name={stream.src ?? "audio"}
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
+ startFrom={Math.floor(startFrom * fps)}
44
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
45
+ muted={volume === 0 || !!ctx?.foreground}
46
+ volume={volume}
47
+ loop={(a.loop ?? 1) > 1}
48
+ playbackRate={playbackRate}
49
+ showInTimeline={false}
50
+ />
51
+ </Sequence>
52
+ );
53
+ })}
54
+ </>
55
+ );
56
+ }
@@ -0,0 +1,135 @@
1
+ import * as React from "react";
2
+ import { Sequence, useVideoConfig } from "remotion";
3
+ import { ComposeContext, useEventContext, useFrameEvents } from "../context/index";
4
+ import JsxParser from "react-jsx-parser";
5
+ import { useTweenBindings } from "../utils/tween";
6
+ import type { Component, EventSpec } from "../schema/index";
7
+
8
+ /**
9
+ * Wraps JsxParser with tween bindings for the current action.
10
+ * Must be a separate component so useTweenBindings follows Rules of Hooks.
11
+ */
12
+ function TweenedJsxParser({
13
+ jsx,
14
+ components,
15
+ data,
16
+ action,
17
+ }: {
18
+ jsx: string;
19
+ components: Record<string, React.ComponentType<any>> | undefined;
20
+ data: Record<string, unknown> | undefined;
21
+ action: { start?: number; end?: number };
22
+ }) {
23
+ const tweenBindings = useTweenBindings(action);
24
+
25
+ return (
26
+ <>
27
+ <JsxParser
28
+ components={components as any}
29
+ bindings={React.useMemo(() => ({ ...data, ...tweenBindings }), [data, tweenBindings])}
30
+ jsx={jsx}
31
+ renderInWrapper={false}
32
+ blacklistedAttrs={[]}
33
+ disableKeyGeneration={true}
34
+ renderError={({ error }) => <div style={{ color: "red", padding: 20, fontSize: "larger" }}>{error}</div>}
35
+ />
36
+ </>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Component leaf — renders a JSX usage expression at runtime.
42
+ * Component tag names are resolved from ComposeContext.components
43
+ * (populated by the engine from root.imports).
44
+ *
45
+ * If the component has an `id`, it registers itself in EventContext so other
46
+ * nodes can mutate its state via `on` event specs.
47
+ */
48
+ export function ComponentLeaf({ stream }: { stream: Component }) {
49
+ const { fps } = useVideoConfig();
50
+ const { components } = React.useContext(ComposeContext);
51
+ const eventCtx = useEventContext();
52
+
53
+ // Event-based state: other nodes can mutate this component's state
54
+ // via registered proxies in the event system.
55
+ const [eventState, setEventState] = React.useState<Record<string, any>>({});
56
+
57
+ // Use useLayoutEffect so registration completes BEFORE any useFrameEvents
58
+ // useEffect fires. Otherwise, at frame 0 the audio node's useFrameEvents
59
+ // evaluates "slide1.current=1" before this component has registered.
60
+ React.useLayoutEffect(() => {
61
+ if (stream.id) {
62
+ eventCtx.register(stream.id, (partial) =>
63
+ setEventState((prev) => ({ ...prev, ...partial })),
64
+ );
65
+ return () => eventCtx.unregister(stream.id);
66
+ }
67
+ }, [stream.id, eventCtx]);
68
+
69
+ // Merge: components registry → stream data (from code fences) → event state
70
+ const bindings = React.useMemo(
71
+ () => ({ ...components, ...stream.data, ...eventState }),
72
+ [stream.data, components, eventState],
73
+ );
74
+
75
+ if (!stream.jsx) return null;
76
+
77
+ return (
78
+ <>
79
+ {stream.actions.map((a) => {
80
+ const start = a.start ?? 0;
81
+ const end = a.end ?? start + 1;
82
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
83
+ return (
84
+ <Sequence
85
+ key={a.id}
86
+ durationInFrames={durFrames}
87
+ from={Math.floor(fps * start)}
88
+ layout="none"
89
+ >
90
+ <EventAwareComponent
91
+ jsx={stream.jsx}
92
+ components={components}
93
+ data={bindings}
94
+ action={a}
95
+ durFrames={durFrames}
96
+ on={stream.on}
97
+ />
98
+ </Sequence>
99
+ );
100
+ })}
101
+ </>
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Inner component that uses useFrameEvents (which requires useCurrentFrame).
107
+ * Must be separate so the hook is called inside Sequence context.
108
+ */
109
+ function EventAwareComponent({
110
+ jsx,
111
+ components,
112
+ data,
113
+ action,
114
+ durFrames,
115
+ on,
116
+ }: {
117
+ jsx: string;
118
+ components: Record<string, React.ComponentType<any>> | undefined;
119
+ data: Record<string, unknown> | undefined;
120
+ action: { start?: number; end?: number };
121
+ durFrames: number;
122
+ on?: EventSpec;
123
+ }) {
124
+ // Fire events at the right frame for this node's timeline
125
+ useFrameEvents(on, durFrames);
126
+
127
+ return (
128
+ <TweenedJsxParser
129
+ jsx={jsx}
130
+ components={components}
131
+ data={data}
132
+ action={action}
133
+ />
134
+ );
135
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Effect stream type — wraps children with keyframe-based CSS animation.
3
+ *
4
+ * Usage in stream tree:
5
+ * { type: "effect", animation: "fadeIn", children: [{ type: "video", ... }] }
6
+ *
7
+ * The animation is computed frame-by-frame via Remotion's interpolate(),
8
+ * producing a wrapper <div> with the animated styles.
9
+ */
10
+ import * as React from "react";
11
+ import { useCurrentFrame, useVideoConfig } from "remotion";
12
+ import { cssJS } from "../utils/index";
13
+ import { useFrameEvents } from "../context/index";
14
+ import { resolveAnimation, interpolateKeyframes } from "./keyframes";
15
+ import type { Effect as EffectStream } from "../schema/index";
16
+
17
+ export function EffectWrapper({
18
+ stream,
19
+ children,
20
+ }: {
21
+ stream: EffectStream;
22
+ children: React.ReactNode;
23
+ }) {
24
+ const frame = useCurrentFrame();
25
+ const { fps, width, height } = useVideoConfig();
26
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
27
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
28
+
29
+ const actions = stream.actions ?? [];
30
+
31
+ const styles = React.useMemo(() => {
32
+ const result: Record<string, string>[] = [];
33
+
34
+ for (const action of actions) {
35
+ const start = Math.ceil(action.start * fps);
36
+ const end = Math.ceil(action.end * fps);
37
+ const durationInFrames = end - start;
38
+ if (durationInFrames <= 0) continue;
39
+
40
+ const animation = stream.animation;
41
+ const timingFn = stream.animationTimingFunction;
42
+ const iterCount = stream.animationIterationCount ?? 1;
43
+ const style = (cssJS(action.style) ?? {}) as Record<string, string>;
44
+
45
+ // Handle iteration count: loop the animation within the action range
46
+ let currentFrame = frame;
47
+ if (iterCount > 0 && durationInFrames > 0) {
48
+ const iteration = Math.floor((frame - start) / durationInFrames);
49
+ if (iteration < iterCount) {
50
+ currentFrame = start + ((frame - start) % durationInFrames);
51
+ }
52
+ }
53
+
54
+ if (currentFrame >= start && currentFrame < end) {
55
+ const actionFrame = currentFrame - start;
56
+
57
+ if (animation) {
58
+ const config = resolveAnimation(animation, stream.customKeyframes);
59
+ if (config) {
60
+ const animStyle = interpolateKeyframes(config, actionFrame, {
61
+ fps,
62
+ durationInSeconds: durationInFrames / fps,
63
+ timingFunction: timingFn,
64
+ });
65
+ if (animStyle) Object.assign(style, animStyle);
66
+ }
67
+ }
68
+
69
+ if (Object.keys(style).length > 0) {
70
+ result.push(style);
71
+ }
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }, [frame, fps, actions, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes]);
77
+
78
+ if (styles.length === 0) return <>{children}</>;
79
+
80
+ return (
81
+ <div
82
+ style={Object.assign({ width, height, position: "absolute" as const, inset: 0 }, ...styles)}
83
+ className="effect"
84
+ >
85
+ {children}
86
+ </div>
87
+ );
88
+ }