@lalalic/markcut 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/AGENTS.md +39 -15
  2. package/README.md +7 -3
  3. package/SKILL.md +26 -172
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +46 -1
  7. package/docs/system-prompt-edit.md +16 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/entry.tsx +8 -2
  16. package/src/player/browser.tsx +178 -54
  17. package/src/player/bundle/player.js +1168 -566
  18. package/src/player/components/EditControls.tsx +92 -0
  19. package/src/player/components/HeaderBar.tsx +60 -0
  20. package/src/player/components/LabelControls.tsx +367 -0
  21. package/src/player/components/SceneThumbnails.tsx +87 -0
  22. package/src/player/components/VariantBar.tsx +39 -0
  23. package/src/player/components/index.ts +5 -0
  24. package/src/player/pipeline.mjs +130 -66
  25. package/src/player/pipeline.ts +3 -1
  26. package/src/player/server-shared.mjs +5 -7
  27. package/src/player/server.mjs +194 -187
  28. package/src/render/cli.mjs +66 -13
  29. package/src/schema/index.ts +20 -23
  30. package/src/types/Audio.tsx +25 -33
  31. package/src/types/Component.tsx +18 -24
  32. package/src/types/Effect.tsx +31 -39
  33. package/src/types/Image.tsx +23 -30
  34. package/src/types/Include.tsx +70 -76
  35. package/src/types/Map.tsx +48 -44
  36. package/src/types/Rhythm.tsx +19 -27
  37. package/src/types/Video.tsx +40 -47
  38. package/src/utils/component-import-map.ts +93 -0
  39. package/src/utils/index.ts +23 -10
  40. package/src/vision/cli.mjs +6 -6
  41. package/templates/courseware/TEMPLATE.md +317 -0
  42. package/templates/courseware/agents/reviewer.md +84 -0
  43. package/templates/courseware/prompts/fix.md +30 -0
  44. package/templates/courseware/prompts/outline.md +53 -0
  45. package/templates/courseware/prompts/scene.md +64 -0
  46. package/tests/fixtures/audio.json +4 -2
  47. package/tests/fixtures/basic.json +2 -1
  48. package/tests/fixtures/component-all.json +4 -2
  49. package/tests/fixtures/components.json +4 -2
  50. package/tests/fixtures/effects.json +12 -6
  51. package/tests/fixtures/full.json +8 -4
  52. package/tests/fixtures/map.json +2 -1
  53. package/tests/fixtures/md/dialogue.md +12 -0
  54. package/tests/fixtures/md/edge-cases.md +9 -0
  55. package/tests/fixtures/scenes.json +4 -2
  56. package/tests/fixtures/subtitle.json +6 -3
  57. package/tests/fixtures/subvideo.json +11 -6
  58. package/tests/fixtures/templates/courseware.md +61 -4
  59. package/tests/fixtures/tween-visual.json +2 -6
  60. package/tests/fixtures/video-series.json +6 -14
  61. package/tests/md-descriptive.test.ts +170 -0
  62. package/tests/render.test.ts +32 -16
  63. package/tests/schema.test.ts +6 -3
  64. package/tests/server.test.ts +9 -6
  65. package/tests/utils.ts +4 -4
  66. package/docs/dynamic-components.md +0 -191
  67. package/docs/templates.md +0 -52
  68. package/scripts/artlist-dl.mjs +0 -190
  69. package/src/player/label-server.mjs +0 -599
@@ -7,8 +7,9 @@
7
7
  * (TTS/STT/durations)
8
8
  */
9
9
  import { execSync, spawn } from "node:child_process";
10
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
11
- import { resolve, dirname, join } from "node:path";
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
11
+ import { createHash } from "node:crypto";
12
+ import { resolve, dirname, join, extname } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
@@ -111,11 +112,42 @@ export function parseArgs(argv) {
111
112
  *
112
113
  * Use --verbose to see every frame line (original behavior).
113
114
  */
115
+ /**
116
+ * Stage local absolute-path assets (TTS audio, TTI/TTV media, subtitles from
117
+ * .markcut/) into ROOT/public/.render-assets/ and rewrite srcs to relative
118
+ * paths, so `npx remotion render` (publicDir = ROOT/public) can serve them.
119
+ * The preview server handles these paths via multi-root serving; the render
120
+ * CLI needs them staged into the one public dir Remotion knows about.
121
+ */
122
+ function stageLocalAssets(tree) {
123
+ const assetsDir = join(ROOT, "public", ".render-assets");
124
+ const seen = new Map();
125
+ const walk = (node) => {
126
+ if (!node || typeof node !== "object") return;
127
+ if (typeof node.src === "string" && node.src.startsWith("/") && !node.src.startsWith("//") && existsSync(node.src)) {
128
+ let staged = seen.get(node.src);
129
+ if (!staged) {
130
+ const name = createHash("md5").update(node.src).digest("hex").slice(0, 12) + extname(node.src);
131
+ mkdirSync(assetsDir, { recursive: true });
132
+ copyFileSync(node.src, join(assetsDir, name));
133
+ staged = `.render-assets/${name}`;
134
+ seen.set(node.src, staged);
135
+ }
136
+ node.src = staged;
137
+ }
138
+ for (const value of Object.values(node)) {
139
+ if (value && typeof value === "object") walk(value);
140
+ }
141
+ };
142
+ walk(tree);
143
+ return tree;
144
+ }
145
+
114
146
  function renderOne(streamTree, aspect, outputPath, verbose) {
115
147
  const dims = ASPECTS[aspect];
116
148
  if (!dims) throw new Error(`Unknown aspect: ${aspect}`);
117
149
 
118
- const adapted = { ...streamTree, width: dims.width, height: dims.height };
150
+ const adapted = stageLocalAssets(JSON.parse(JSON.stringify({ ...streamTree, width: dims.width, height: dims.height })));
119
151
  const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
120
152
  mkdirSync(dirname(tmpProps), { recursive: true });
121
153
  writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
@@ -211,21 +243,19 @@ async function main() {
211
243
  }
212
244
 
213
245
  if (args.label || args.edit) {
214
- const playerServer = args.label
215
- ? join(__dirname, "..", "player", "label-server.mjs")
216
- : join(__dirname, "..", "player", "server.mjs");
246
+ const playerServer = join(__dirname, "..", "player", "server.mjs");
217
247
  if (!existsSync(playerServer)) {
218
248
  console.error("Player server not found at", playerServer);
219
249
  process.exit(1);
220
250
  }
221
- const modeFlags = args.noBrowser ? "--no-browser" : "";
251
+ const labelFlag = args.label ? "--label" : "";
222
252
  const editFlag = args.edit ? "--edit" : "";
223
253
  const portFlag = `--port=${args.port || 3001}`;
224
254
  const fileFlag = args.file || join(ROOT, "video.json");
225
255
  const port = args.port || 3001;
226
256
  // Pass variant flags to the server
227
257
  const variantFlags = args.variant.map(v => `--variant=${v}`);
228
- const serverArgs = [playerServer, resolve(fileFlag), modeFlags, editFlag, `--port=${port}`, ...variantFlags].filter(Boolean);
258
+ const serverArgs = [playerServer, resolve(fileFlag), labelFlag, editFlag, portFlag, ...variantFlags].filter(Boolean);
229
259
  const child = spawn("node", serverArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"] });
230
260
  let serverReady = false;
231
261
  let stdoutBuffer = "";
@@ -235,7 +265,7 @@ async function main() {
235
265
  process.stdout.write(chunk);
236
266
  if (!serverReady && !args.noBrowser) {
237
267
  stdoutBuffer += chunk.toString();
238
- if (stdoutBuffer.includes("Player ready") || stdoutBuffer.includes("Label Preview")) {
268
+ if (stdoutBuffer.includes("Player ready")) {
239
269
  serverReady = true;
240
270
 
241
271
  try {
@@ -262,10 +292,12 @@ async function main() {
262
292
 
263
293
  if (args.command === "render") {
264
294
  let streamTree;
295
+ let rawInput = "";
265
296
 
266
297
  if (args.file) {
267
298
  const filePath = resolve(args.file);
268
299
  const raw = readFileSync(filePath, "utf-8");
300
+ rawInput = raw;
269
301
 
270
302
  // Helper: all generated artifacts live under .markcut/generated/
271
303
  function generatedDir(filePath, sub) {
@@ -285,14 +317,14 @@ async function main() {
285
317
  // Helper: resolve variants from the parsed descriptive root
286
318
  async function resolveWithVariants(descriptive, options) {
287
319
  const { resolveVariantOverrides, compileDescriptiveRoot, resolveAll } = await import("../player/pipeline.mjs");
288
- const { parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
320
+ const { parseMarkdownVariants } = await import("../player/pipeline.mjs");
289
321
 
290
322
  let resolved = descriptive;
291
323
 
292
324
  // If variants are specified, apply variant-prefixed overrides
293
325
  if (args.variant && args.variant.length > 0) {
294
326
  // Also apply root config overrides from variant sections
295
- const parsed = parseMarkdownDescriptive(raw);
327
+ const parsed = parseMarkdownVariants(raw);
296
328
  const variantRoot = parsed.variants.get(args.variant[0]);
297
329
  if (variantRoot) {
298
330
  // Merge variant root config into base (tts, stt, width, height, etc.)
@@ -308,9 +340,9 @@ async function main() {
308
340
  }
309
341
 
310
342
  if (filePath.endsWith(".md")) {
311
- const { parseMarkdownDescriptive } = await import("../player/pipeline.mjs");
343
+ const { parseMarkdownVariants } = await import("../player/pipeline.mjs");
312
344
  const fileDir = dirname(filePath);
313
- const parsed = parseMarkdownDescriptive(raw);
345
+ const parsed = parseMarkdownVariants(raw);
314
346
  streamTree = await resolveWithVariants(parsed.base, {
315
347
  baseDir: fileDir,
316
348
  scriptOutputDir: generatedDir(filePath, "tts"),
@@ -340,6 +372,27 @@ async function main() {
340
372
  process.exit(1);
341
373
  }
342
374
 
375
+ // Bundle the ```js imports``` component block (the preview server does this
376
+ // post-compile; the render CLI must do it too, otherwise jsx components are
377
+ // unregistered and slides render as unstyled raw text).
378
+ if (!streamTree.imports) {
379
+ const fenceMatch = rawInput.match(/^(```|~~~)\s*js imports\s*\n([\s\S]*?)^\1\s*$/m);
380
+ const rawSource = fenceMatch ? fenceMatch[2] : (streamTree.importsBlock ?? null);
381
+ if (rawSource && rawSource.trim()) {
382
+ const { parseImportsBlock, extractDependencySpecs } = await import("../player/pipeline.mjs");
383
+ const { bundleFromEntries } = await import("../player/bundler.mjs");
384
+ const entries = parseImportsBlock(rawSource);
385
+ const extraSpecs = extractDependencySpecs(rawSource);
386
+ const bundleDir = join(ROOT, "public", ".render-assets");
387
+ mkdirSync(bundleDir, { recursive: true });
388
+ const bundle = await bundleFromEntries(entries, extraSpecs, rawSource, bundleDir);
389
+ if (bundle.url) {
390
+ streamTree.imports = ".render-assets/" + bundle.url.split("/").pop();
391
+ console.log(` \u2705 components \u2192 ${bundle.exports.join(", ")}`);
392
+ }
393
+ }
394
+ }
395
+
343
396
  const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
344
397
  await renderOne(streamTree, "16x9", output, args.verbose);
345
398
 
@@ -6,25 +6,21 @@ import { uid } from "../utils/index";
6
6
  * Render-only — no prompts, no async transforms, no providers.
7
7
  *
8
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.
9
+ * Leaf streams (video/audio/image/component) carry timing on their base
10
+ * fields (start/end/duration/startFrom/endAt) describing when they appear
11
+ * on the timeline.
11
12
  *
12
13
  * Subtitles are a root-level overlay: root.subtitle points to a VTT file
13
14
  * that covers the full video duration. Generated by the TTS pipeline.
14
15
  */
15
16
 
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>;
17
+ /**
18
+ * Leaf timing lives directly on BaseShape (start/end/duration/startFrom/endAt).
19
+ * The old `actions[]` array has been removed — every leaf occupies exactly one
20
+ * span on its parent's timeline. `end` is the source of truth for duration;
21
+ * `duration` is a normalized convenience (end = start + duration when end is
22
+ * absent). `loop`/`volume`/`style` moved onto the stream type itself.
23
+ */
28
24
 
29
25
  export const eventSpec = z.object({
30
26
  when: z.string().describe("'start' | 'end' | '50%' | '2.5s'"),
@@ -39,6 +35,11 @@ const BaseShape = {
39
35
  style: z.string().optional().describe("inline css"),
40
36
  visible: z.boolean().default(true),
41
37
  isBackground: z.boolean().optional(),
38
+ start: z.number().min(0).default(0).describe("seconds, relative to parent timeline (0 inside series parents)"),
39
+ end: z.number().min(0).optional().describe("seconds, relative to parent timeline — source of truth for duration"),
40
+ duration: z.number().min(0).optional().describe("convenience; normalized to end = start + duration when end is absent"),
41
+ startFrom: z.number().min(0).optional().describe("trim seconds from source start (video/audio)"),
42
+ endAt: z.number().min(0).optional().describe("trim seconds at source end (video/audio)"),
42
43
  durationInSeconds: z.number().optional().describe("set by engine; do not edit by hand"),
43
44
  on: eventSpec.optional().describe("event that fires at a specific frame, mutating registered component state"),
44
45
  };
@@ -102,7 +103,7 @@ export const video = base.extend({
102
103
  playbackRate: z.number().optional(),
103
104
  width: z.number().default(1080),
104
105
  height: z.number().default(1920),
105
- actions: z.array(action).min(1).default(() => [action.parse({})]),
106
+ loop: z.number().int().min(1).optional().describe(">1 = loop count"),
106
107
  });
107
108
  export type Video = z.infer<typeof video>;
108
109
 
@@ -111,7 +112,8 @@ export const audio = base.extend({
111
112
  src: z.string().optional(),
112
113
  volume: z.number().min(0).max(1).default(1),
113
114
  foreground: z.boolean().optional().describe("ducks parent video audio while playing"),
114
- actions: z.array(action).min(1).default(() => [action.parse({})]),
115
+ loop: z.number().int().min(1).optional().describe(">1 = loop count"),
116
+ speaker: z.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue"),
115
117
  });
116
118
  export type Audio = z.infer<typeof audio>;
117
119
 
@@ -119,7 +121,6 @@ export const image = base.extend({
119
121
  type: z.literal("image").default("image"),
120
122
  src: z.string().optional(),
121
123
  fit: z.enum(["contain", "cover", "fill"]).default("contain"),
122
- actions: z.array(action).min(1).default(() => [action.parse({})]),
123
124
  });
124
125
  export type Image = z.infer<typeof image>;
125
126
 
@@ -134,7 +135,6 @@ export const component = base.extend({
134
135
  type: z.literal("component").default("component"),
135
136
  jsx: z.string().describe("usage JSX expression compiled at runtime; tag names resolved from imports"),
136
137
  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
138
  });
139
139
  export type Component = z.infer<typeof component>;
140
140
 
@@ -153,7 +153,6 @@ export const effect = base.extend({
153
153
  .optional()
154
154
  .describe('inline keyframes: { "0": { opacity: "0" }, "100": { opacity: "1" } }'),
155
155
  children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
156
- actions: z.array(action).min(1).default(() => [action.parse({})]),
157
156
  });
158
157
  export type Effect = z.infer<typeof effect>;
159
158
 
@@ -166,7 +165,6 @@ export const rhythm = base.extend({
166
165
  volume: z.number().min(0).max(1).default(1),
167
166
  spots: z.array(z.number()).optional().describe("pre-computed beat timestamps in seconds"),
168
167
  children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
169
- actions: z.array(action).min(1).default(() => [action.parse({})]),
170
168
  });
171
169
  export type Rhythm = z.infer<typeof rhythm>;
172
170
 
@@ -177,7 +175,7 @@ export type Rhythm = z.infer<typeof rhythm>;
177
175
  // - A scene-based video.json (has `meta` and `scenes`)
178
176
  //
179
177
  // Usage:
180
- // { type: "include", src: "./path/to/video.json", actions: [{ start: 0, end: 5 }] }
178
+ // { type: "include", src: "./path/to/video.json", start: 0, end: 5 }
181
179
  //
182
180
  // Falls back to inline `children` if `src` is not set (legacy behavior).
183
181
  // ---------------------------------------------------------------------------
@@ -186,7 +184,6 @@ export const include = base.extend({
186
184
  src: z.string().optional().describe("path or URL to video JSON file (stream tree or scene-based)"),
187
185
  volume: z.number().min(0).max(1).default(1),
188
186
  children: z.array(z.lazy((): z.ZodTypeAny => stream)).default(() => []),
189
- actions: z.array(action).min(1).default(() => [action.parse({})]),
190
187
  imports: z.string().optional().describe("component bundle URL for sub-video (set by engine)"),
191
188
  });
192
189
  export type Include = z.infer<typeof include>;
@@ -234,7 +231,7 @@ export const mapStream = base.extend({
234
231
  mapType: z.enum(["roadmap", "satellite", "hybrid", "terrain"]).default("roadmap").describe("Google Maps style"),
235
232
  travelMode: z.enum(["DRIVING", "WALKING", "BICYCLING", "TRANSIT"]).default("DRIVING").describe("Directions API travel mode"),
236
233
  routeMarker: z.string().default("🚗").describe("emoji/character for the animated traveling marker"),
237
- actions: z.array(action).min(1).default(() => [action.parse({})]),
234
+ googleMapsApiKey: z.string().optional().describe("injected by compiler from GOOGLE_MAPS_API_KEY env var"),
238
235
  });
239
236
  export type MapStream = z.infer<typeof mapStream>;
240
237
 
@@ -13,44 +13,36 @@ export function AudioLeaf({ stream }: { stream: Audio }) {
13
13
  const { fps } = useVideoConfig();
14
14
  const environment = useRemotionEnvironment();
15
15
  const ctx = React.useContext(AudioContext);
16
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
16
+ const start = stream.start ?? 0;
17
+ const end = stream.end ?? start + (stream.duration ?? 1);
18
+ const totalDur = stream.durationInSeconds ?? end;
17
19
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
18
20
  if (!stream.src) return null;
19
21
  if (environment.isStudio) return null;
20
22
 
21
23
  const resolvedSrc = resolveAudioSrc(stream.src);
22
-
24
+ const startFrom = stream.startFrom ?? 0;
25
+ const endAt = stream.endAt ?? totalDur;
26
+ const volume = stream.volume ?? 1;
27
+ const playbackRate = stream.loop ? 1 : toPlaybackRate((endAt - startFrom) / (end - start));
23
28
  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
- </>
29
+ <Sequence
30
+ name={stream.src ?? "audio"}
31
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
32
+ from={Math.floor(fps * start)}
33
+ layout="none"
34
+ showInTimeline={false}
35
+ >
36
+ <RemotionAudio
37
+ src={resolvedSrc}
38
+ startFrom={Math.floor(startFrom * fps)}
39
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
40
+ muted={volume === 0 || !!ctx?.foreground}
41
+ volume={volume}
42
+ loop={(stream.loop ?? 1) > 1}
43
+ playbackRate={playbackRate}
44
+ showInTimeline={false}
45
+ />
46
+ </Sequence>
55
47
  );
56
48
  }
@@ -74,31 +74,25 @@ export function ComponentLeaf({ stream }: { stream: Component }) {
74
74
 
75
75
  if (!stream.jsx) return null;
76
76
 
77
+ const start = stream.start ?? 0;
78
+ const end = stream.end ?? start + (stream.duration ?? 1);
79
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
80
+
77
81
  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
- </>
82
+ <Sequence
83
+ durationInFrames={durFrames}
84
+ from={Math.floor(fps * start)}
85
+ layout="none"
86
+ >
87
+ <EventAwareComponent
88
+ jsx={stream.jsx}
89
+ components={components}
90
+ data={bindings}
91
+ action={{ start, end }}
92
+ durFrames={durFrames}
93
+ on={stream.on}
94
+ />
95
+ </Sequence>
102
96
  );
103
97
  }
104
98
 
@@ -23,57 +23,49 @@ export function EffectWrapper({
23
23
  }) {
24
24
  const frame = useCurrentFrame();
25
25
  const { fps, width, height } = useVideoConfig();
26
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
26
+ const startSec = stream.start ?? 0;
27
+ const endSec = stream.end ?? startSec + (stream.duration ?? 1);
28
+ const totalDur = stream.durationInSeconds ?? endSec;
27
29
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
28
30
 
29
- const actions = stream.actions ?? [];
30
-
31
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;
32
+ const start = Math.ceil(startSec * fps);
33
+ const end = Math.ceil(endSec * fps);
34
+ const durationInFrames = end - start;
35
+ if (durationInFrames <= 0) return [] as Record<string, string>[];
39
36
 
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>;
37
+ const animation = stream.animation;
38
+ const timingFn = stream.animationTimingFunction;
39
+ const iterCount = stream.animationIterationCount ?? 1;
40
+ const style = (cssJS(stream.style) ?? {}) as Record<string, string>;
44
41
 
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
- }
42
+ // Handle iteration count: loop the animation within the span
43
+ let currentFrame = frame;
44
+ if (iterCount > 0 && durationInFrames > 0) {
45
+ const iteration = Math.floor((frame - start) / durationInFrames);
46
+ if (iteration < iterCount) {
47
+ currentFrame = start + ((frame - start) % durationInFrames);
52
48
  }
49
+ }
53
50
 
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
- }
51
+ if (currentFrame >= start && currentFrame < end) {
52
+ const actionFrame = currentFrame - start;
68
53
 
69
- if (Object.keys(style).length > 0) {
70
- result.push(style);
54
+ if (animation) {
55
+ const config = resolveAnimation(animation, stream.customKeyframes);
56
+ if (config) {
57
+ const animStyle = interpolateKeyframes(config, actionFrame, {
58
+ fps,
59
+ durationInSeconds: durationInFrames / fps,
60
+ timingFunction: timingFn,
61
+ });
62
+ if (animStyle) Object.assign(style, animStyle);
71
63
  }
72
64
  }
73
65
  }
74
66
 
75
- return result;
76
- }, [frame, fps, actions, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes]);
67
+ return Object.keys(style).length > 0 ? [style] : [];
68
+ }, [frame, fps, startSec, endSec, stream.animation, stream.animationTimingFunction, stream.animationIterationCount, stream.customKeyframes, stream.style]);
77
69
 
78
70
  if (styles.length === 0) return <>{children}</>;
79
71
 
@@ -12,40 +12,33 @@ function resolveImageSrc(src: string): string {
12
12
 
13
13
  export function ImageLeaf({ stream }: { stream: Image }) {
14
14
  const { fps } = useVideoConfig();
15
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
15
+ const start = stream.start ?? 0;
16
+ const end = stream.end ?? start + (stream.duration ?? 1);
17
+ const totalDur = stream.durationInSeconds ?? end;
16
18
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
17
19
  if (!stream.src) return null;
18
20
  const resolvedSrc = resolveImageSrc(stream.src);
19
21
 
20
22
  return (
21
- <>
22
- {stream.actions.map((a) => {
23
- const start = a.start ?? 0;
24
- const end = a.end ?? start + 1;
25
- return (
26
- <Sequence
27
- key={a.id}
28
- durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
29
- from={Math.floor(fps * start)}
30
- layout="none"
31
- >
32
- <FrameSyncStyle style={cssJS(a.style)}>
33
- <Img
34
- src={resolvedSrc}
35
- style={{
36
- width: "100%",
37
- height: "100%",
38
- objectFit: stream.fit,
39
- }}
40
- onDragStart={(e) => {
41
- e.stopPropagation();
42
- return false;
43
- }}
44
- />
45
- </FrameSyncStyle>
46
- </Sequence>
47
- );
48
- })}
49
- </>
23
+ <Sequence
24
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
25
+ from={Math.floor(fps * start)}
26
+ layout="none"
27
+ >
28
+ <FrameSyncStyle style={cssJS(stream.style)}>
29
+ <Img
30
+ src={resolvedSrc}
31
+ style={{
32
+ width: "100%",
33
+ height: "100%",
34
+ objectFit: stream.fit,
35
+ }}
36
+ onDragStart={(e) => {
37
+ e.stopPropagation();
38
+ return false;
39
+ }}
40
+ />
41
+ </FrameSyncStyle>
42
+ </Sequence>
50
43
  );
51
44
  }