@lalalic/markcut 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +39 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -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/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1168 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +87 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +194 -187
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
@@ -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
  }
@@ -104,17 +104,20 @@ function useSubVideoRegistry(importsUrl: string | undefined):
104
104
  * registered by the sub-video are available during its rendering.
105
105
  *
106
106
  * Usage:
107
- * { type: "include", src: "./path/to/video.json", actions: [{ start: 0, end: 5 }] }
107
+ * { type: "include", src: "./path/to/video.json", start: 0, end: 5 }
108
108
  */
109
109
  export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
110
110
  const { fps: parentFps, width: parentWidth, height: parentHeight } = useVideoConfig();
111
111
  const parentCompose = React.useContext(ComposeContext);
112
112
  const { Container } = parentCompose;
113
113
  const parentAudio = React.useContext(AudioContext);
114
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
114
+ const start = stream.start ?? 0;
115
+ const end = stream.end ?? (stream.durationInSeconds ?? (start + (stream.duration ?? 1)));
116
+ const totalDur = stream.durationInSeconds ?? end;
115
117
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * parentFps)));
116
118
 
117
- if (!stream.actions?.length) return null;
119
+ const hasContent = !!stream.src || (stream.children?.length ?? 0) > 0;
120
+ if (!hasContent) return null;
118
121
 
119
122
  // ── External JSON loading ─────────────────────────────────────────────
120
123
  const [externalData, setExternalData] = React.useState<unknown | null>(null);
@@ -296,87 +299,78 @@ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
296
299
  );
297
300
  }, [externalData, loadError, parentFps, parentWidth, parentHeight]);
298
301
 
299
- // ── Action-based rendering ───────────────────────────────────────
300
- const renderAction = (a: IncludeStream["actions"][number]) => {
301
- const start = a.start ?? 0;
302
- const end = a.end ?? (stream.durationInSeconds ?? start + 1);
303
- const dur = Math.max(0.1, end - start);
304
- const durFrames = Math.max(1, Math.floor(parentFps * dur));
305
-
306
- // ── Inline children fallback (legacy) ─────────────────────────
307
- if (!stream.src && stream.children?.length) {
308
- return (
309
- <Sequence
310
- key={a.id}
311
- durationInFrames={durFrames}
312
- from={Math.floor(parentFps * start)}
313
- layout="none"
314
- showInTimeline={false}
315
- >
316
- <Container
317
- id={stream.id}
318
- type="include"
319
- style={{
320
- ...cssJS(stream.style) as React.CSSProperties,
321
- width: parentWidth,
322
- height: parentHeight,
323
- overflow: "hidden",
324
- position: "relative",
325
- }}
326
- className={`include ${toClassName(stream.id ?? "")}`}
327
- >
328
- <FolderLeaf stream={stream as any} />
329
- </Container>
330
- </Sequence>
331
- );
332
- }
333
-
334
- // ── External src rendering ─────────────────────────────────────
335
- return (
336
- <Sequence
337
- key={a.id}
338
- durationInFrames={durFrames}
339
- from={Math.floor(parentFps * start)}
340
- layout="none"
341
- showInTimeline={false}
302
+ // ── Single-span rendering ────────────────────────────────────────
303
+ const dur = Math.max(0.1, end - start);
304
+ const durFrames = Math.max(1, Math.floor(parentFps * dur));
305
+
306
+ // ── Inline children fallback (legacy) ─────────────────────────
307
+ let innerNode: React.ReactNode;
308
+ if (!stream.src && stream.children?.length) {
309
+ innerNode = (
310
+ <Container
311
+ id={stream.id}
312
+ type="include"
313
+ style={{
314
+ ...cssJS(stream.style) as React.CSSProperties,
315
+ width: parentWidth,
316
+ height: parentHeight,
317
+ overflow: "hidden",
318
+ position: "relative",
319
+ }}
320
+ className={`include ${toClassName(stream.id ?? "")}`}
342
321
  >
343
- {externalData || loadError ? (
344
- <div
345
- style={{
346
- width: parentWidth,
347
- height: parentHeight,
348
- overflow: "hidden",
349
- position: "relative",
350
- }}
351
- >
352
- {renderExternalContent()}
353
- </div>
354
- ) : (
355
- <div
356
- style={{
357
- width: parentWidth,
358
- height: parentHeight,
359
- display: "flex",
360
- alignItems: "center",
361
- justifyContent: "center",
362
- color: "#666",
363
- fontSize: 20,
364
- fontFamily: "monospace",
365
- }}
366
- >
367
- Loading… {stream.src}
368
- </div>
369
- )}
370
- </Sequence>
322
+ <FolderLeaf stream={stream as any} />
323
+ </Container>
371
324
  );
372
- };
325
+ } else if (externalData || loadError) {
326
+ innerNode = (
327
+ <div
328
+ style={{
329
+ width: parentWidth,
330
+ height: parentHeight,
331
+ overflow: "hidden",
332
+ position: "relative",
333
+ }}
334
+ >
335
+ {renderExternalContent()}
336
+ </div>
337
+ );
338
+ } else {
339
+ innerNode = (
340
+ <div
341
+ style={{
342
+ width: parentWidth,
343
+ height: parentHeight,
344
+ display: "flex",
345
+ alignItems: "center",
346
+ justifyContent: "center",
347
+ color: "#666",
348
+ fontSize: 20,
349
+ fontFamily: "monospace",
350
+ }}
351
+ >
352
+ Loading… {stream.src}
353
+ </div>
354
+ );
355
+ }
356
+
357
+ const sequence = (
358
+ <Sequence
359
+ durationInFrames={durFrames}
360
+ from={Math.floor(parentFps * start)}
361
+ layout="none"
362
+ showInTimeline={false}
363
+ >
364
+ {innerNode}
365
+ </Sequence>
366
+ );
373
367
 
374
368
  // ── Determine if we need a nested ComposeContext ───────────────────
375
369
  const needsNestedContext = subRegistry !== null && subRegistry !== parentCompose.components;
376
370
 
377
371
  const inner = (
378
372
  <AudioContext.Provider value={audioCtx as any}>
379
- {stream.actions.map(renderAction)}
373
+ {sequence}
380
374
  </AudioContext.Provider>
381
375
  );
382
376