@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,180 @@
1
+ import * as React from "react";
2
+ import { Sequence, Series, Loop, useVideoConfig } from "remotion";
3
+ import { TransitionSeries, linearTiming } from "@remotion/transitions";
4
+ import { fade } from "@remotion/transitions/fade";
5
+ import { slide } from "@remotion/transitions/slide";
6
+ import { wipe } from "@remotion/transitions/wipe";
7
+ import { flip } from "@remotion/transitions/flip";
8
+ import { clockWipe } from "@remotion/transitions/clock-wipe";
9
+
10
+ import { ComposeContext, AudioContext, useFrameEvents } from "../context/index";
11
+ import { cssJS, toClassName } from "../utils/index";
12
+ import type { Folder as FolderStream, Stream } from "../schema/index";
13
+
14
+ import { VideoLeaf } from "./Video";
15
+ import { AudioLeaf } from "./Audio";
16
+ import { ImageLeaf } from "./Image";
17
+ import { ComponentLeaf } from "./Component";
18
+ import { RhythmLeaf } from "./Rhythm";
19
+ import { MapLeaf } from "./Map";
20
+ import { IncludeLeaf } from "./Include";
21
+ import { SceneLeaf } from "./Scene";
22
+ import { EffectWrapper } from "./Effect";
23
+
24
+ const Leaves: Record<string, React.ComponentType<{ stream: any }>> = {
25
+ video: VideoLeaf,
26
+ audio: AudioLeaf,
27
+ image: ImageLeaf,
28
+ component: ComponentLeaf,
29
+ rhythm: RhythmLeaf,
30
+ map: MapLeaf,
31
+ include: IncludeLeaf,
32
+ scene: SceneLeaf,
33
+ };
34
+
35
+ const TransitionPresets: Record<string, (opts?: any) => any> = {
36
+ fade,
37
+ slide,
38
+ wipe,
39
+ flip,
40
+ clockWipe,
41
+ };
42
+
43
+ const NotSeries = ({ children }: { children: React.ReactNode }) => <>{children}</>;
44
+ NotSeries.Sequence = ({ children }: { children: React.ReactNode; durationInFrames?: number; layout?: any }) => (
45
+ <>{children}</>
46
+ );
47
+
48
+ export function FolderLeaf({ stream }: { stream: FolderStream }) {
49
+ const { fps, width, height } = useVideoConfig();
50
+ const { Container } = React.useContext(ComposeContext);
51
+ const parentAudio = React.useContext(AudioContext);
52
+ const totalDur = stream.durationInSeconds ?? 1;
53
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
54
+
55
+ const isSeries = !!stream.isSeries;
56
+ const transition = stream.transition;
57
+ const transitionTime = stream.transitionTime ?? 0.5;
58
+ const isRoot = stream.id === "root";
59
+
60
+ const visibleChildren = (stream.children as Stream[]).filter((c) => c.visible !== false);
61
+
62
+ // Background children are rendered outside the series (parallel overlays),
63
+ // so TransitionSeries doesn't reject the <Loop> wrapper.
64
+ const bgChildren = visibleChildren.filter((c) => c.isBackground);
65
+ const seriesChildren = isSeries ? visibleChildren.filter((c) => !c.isBackground) : visibleChildren;
66
+
67
+ // When all non-background series children are audio, skip transitions to
68
+ // avoid audio overlap (both audio tracks play simultaneously during a fade).
69
+ const allAudio = seriesChildren.length > 0 && seriesChildren.every((c) => c.type === "audio");
70
+
71
+ // Skip transitions when all series children are audio (avoids audio overlap).
72
+ const effectiveTransition = allAudio ? undefined : transition;
73
+
74
+ const TypedSeries: any = React.useMemo(() => {
75
+ if (!isSeries) return NotSeries;
76
+ return effectiveTransition ? TransitionSeries : Series;
77
+ }, [isSeries, effectiveTransition]);
78
+
79
+ const transEl = React.useMemo(() => {
80
+ if (!isSeries || !effectiveTransition) return null;
81
+ const presentation = TransitionPresets[transition]?.(
82
+ transition === "clockWipe" ? { width, height } : undefined,
83
+ );
84
+ return (
85
+ <TransitionSeries.Transition
86
+ presentation={presentation}
87
+ timing={linearTiming({ durationInFrames: Math.floor(fps * transitionTime) })}
88
+ />
89
+ );
90
+ }, [isSeries, effectiveTransition, transitionTime, fps, width, height]);
91
+
92
+ const sequences = seriesChildren
93
+ .map((child) => {
94
+ const dur = child.durationInSeconds ?? 0;
95
+ const durFrames = Math.max(1, Math.floor(dur * fps));
96
+ const SequenceWrap = TypedSeries.Sequence ?? Sequence;
97
+ const isLeaf = child.type !== "folder" && child.type !== "root" && child.type !== "effect";
98
+ const childContent = isLeaf
99
+ ? React.createElement(Leaves[child.type] ?? (() => null), { stream: child })
100
+ : child.type === "effect"
101
+ ? <EffectWrapper stream={child as any}><FolderLeaf stream={child as any} /></EffectWrapper>
102
+ : React.createElement(FolderLeaf, { stream: child as FolderStream });
103
+ return (
104
+ <SequenceWrap key={child.id} durationInFrames={durFrames} layout="none">
105
+ <Container
106
+ id={child.id}
107
+ type={child.type}
108
+ style={cssJS(child.style) as React.CSSProperties}
109
+ className={`${child.type} ${toClassName(child.id ?? "")}`}
110
+ >
111
+ {childContent}
112
+ </Container>
113
+ </SequenceWrap>
114
+ );
115
+ })
116
+ .filter(Boolean);
117
+
118
+ // Background children: rendered as parallel loops outside the series
119
+ const bgContent = bgChildren.length > 0 && (
120
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
121
+ {bgChildren.map((child) => {
122
+ const dur = child.durationInSeconds ?? 0;
123
+ const durFrames = Math.max(1, Math.floor(dur * fps));
124
+ const isLeaf = child.type !== "folder" && child.type !== "root" && child.type !== "effect";
125
+ const childContent = isLeaf
126
+ ? React.createElement(Leaves[child.type] ?? (() => null), { stream: child })
127
+ : child.type === "effect"
128
+ ? <EffectWrapper stream={child as any}><FolderLeaf stream={child as any} /></EffectWrapper>
129
+ : React.createElement(FolderLeaf, { stream: child as FolderStream });
130
+ const wrapped = (
131
+ <Container
132
+ id={child.id}
133
+ type={child.type}
134
+ style={cssJS(child.style) as React.CSSProperties}
135
+ className={`${child.type} ${toClassName(child.id ?? "")}`}
136
+ >
137
+ {childContent}
138
+ </Container>
139
+ );
140
+ const times = Math.max(1, Math.ceil((stream.durationInSeconds! * fps) / durFrames));
141
+ return (
142
+ <Loop key={child.id} durationInFrames={durFrames} times={times} showInTimeline={false}>
143
+ {wrapped}
144
+ </Loop>
145
+ );
146
+ })}
147
+ </div>
148
+ );
149
+
150
+ // interleave transitions
151
+ if (isSeries && transEl) {
152
+ for (let i = 1; i < sequences.length; i += 2) {
153
+ sequences.splice(i, 0, React.cloneElement(transEl, { key: `t${i}` } as any));
154
+ }
155
+ }
156
+
157
+ const audioCtx = React.useMemo(
158
+ () => (stream.type !== "folder" ? { id: stream.id, parent: parentAudio } : parentAudio),
159
+ [stream.id, stream.type, parentAudio],
160
+ );
161
+
162
+ if (visibleChildren.length === 0 || stream.visible === false) return null;
163
+
164
+ const containerStyle = cssJS(stream.style) as React.CSSProperties;
165
+ const orientation = isRoot ? (width > height ? "landscape" : "portrait") : "";
166
+
167
+ return (
168
+ <AudioContext.Provider value={audioCtx as any}>
169
+ <Container
170
+ id={stream.id}
171
+ type={stream.type}
172
+ style={containerStyle}
173
+ className={`${orientation} ${stream.type}`.trim()}
174
+ >
175
+ {isSeries ? <TypedSeries>{sequences}</TypedSeries> : sequences}
176
+ {bgContent}
177
+ </Container>
178
+ </AudioContext.Provider>
179
+ );
180
+ }
@@ -0,0 +1,51 @@
1
+ import * as React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+
4
+ /**
5
+ * Wraps children in a <div> that syncs CSS animations to the current Remotion frame.
6
+ *
7
+ * CSS @keyframes animations are wall-clock-based and don't respond to
8
+ * Remotion's frame-based seeking. This component freezes the animation
9
+ * at the correct time by setting:
10
+ * animation-play-state: paused
11
+ * animation-delay: -${currentTime}s
12
+ *
13
+ * If the style has no `animation` property, it applies the style directly
14
+ * with no overhead.
15
+ */
16
+ export function FrameSyncStyle({
17
+ style,
18
+ children,
19
+ }: {
20
+ style: Record<string, unknown>;
21
+ children: React.ReactNode;
22
+ }) {
23
+ const frame = useCurrentFrame();
24
+ const { fps } = useVideoConfig();
25
+
26
+ const hasAnimation = "animation" in style;
27
+
28
+ const mergedStyle = React.useMemo<React.CSSProperties>(() => {
29
+ if (!hasAnimation) return style as React.CSSProperties;
30
+
31
+ const currentTime = frame / fps;
32
+ return {
33
+ ...style,
34
+ animationPlayState: "paused",
35
+ animationDelay: `-${currentTime}s`,
36
+ } as React.CSSProperties;
37
+ }, [style, hasAnimation, frame, fps]);
38
+
39
+ return (
40
+ <div
41
+ style={{
42
+ position: "absolute",
43
+ inset: 0,
44
+ overflow: "hidden",
45
+ ...mergedStyle,
46
+ }}
47
+ >
48
+ {children}
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,51 @@
1
+ import * as React from "react";
2
+ import { Sequence, Img, useVideoConfig, staticFile } from "remotion";
3
+ import { cssJS } from "../utils/index";
4
+ import { useFrameEvents } from "../context/index";
5
+ import type { Image } from "../schema/index";
6
+ import { FrameSyncStyle } from "./FrameSyncStyle";
7
+
8
+ function resolveImageSrc(src: string): string {
9
+ if (/^(https?:|data:|blob:|file:|\/)/.test(src)) return src;
10
+ return staticFile(src);
11
+ }
12
+
13
+ export function ImageLeaf({ stream }: { stream: Image }) {
14
+ const { fps } = useVideoConfig();
15
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
16
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
17
+ if (!stream.src) return null;
18
+ const resolvedSrc = resolveImageSrc(stream.src);
19
+
20
+ 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
+ </>
50
+ );
51
+ }
@@ -0,0 +1,394 @@
1
+ import * as React from "react";
2
+ import { AbsoluteFill, Sequence, useVideoConfig, delayRender, continueRender, staticFile, Audio } from "remotion";
3
+ import { ComposeContext, AudioContext, useFrameEvents } from "../context/index";
4
+ import { cssJS, toClassName, getDurationInSeconds, type DurationStream } from "../utils/index";
5
+ import type { Include as IncludeStream, Root } from "../schema/index";
6
+ import { FolderLeaf } from "./Folder";
7
+
8
+ // Aspect dimensions for scene-based video content
9
+ const ASPECT_DIMS: Record<string, { width: number; height: number }> = {
10
+ "16x9": { width: 1920, height: 1080 },
11
+ "9x16": { width: 1080, height: 1920 },
12
+ "1x1": { width: 1080, height: 1080 },
13
+ };
14
+
15
+ interface SceneBasedVideo {
16
+ meta: { title: string; fps: number; aspects?: string[] };
17
+ voiceover?: { tts: string; voice: string };
18
+ bgm?: { src: string; baseVolume: number };
19
+ scenes: Array<{
20
+ id: string;
21
+ start?: number;
22
+ duration?: number;
23
+ component?: string;
24
+ props?: { headline?: string; subhead?: string; [k: string]: unknown };
25
+ voiceover?: { audio?: string };
26
+ }>;
27
+ }
28
+
29
+ /**
30
+ * Determines whether a parsed JSON value is a scene-based video.json
31
+ * (has `meta` and `scenes`) vs a stream tree (has `type: "root"` or a `root` property).
32
+ */
33
+ function isSceneBased(data: unknown): data is SceneBasedVideo {
34
+ if (!data || typeof data !== "object") return false;
35
+ const d = data as Record<string, unknown>;
36
+ return typeof d.meta === "object" && d.meta !== null && Array.isArray(d.scenes);
37
+ }
38
+
39
+ /**
40
+ * Resolve a relative src path via staticFile() if it's not an absolute URL or path.
41
+ */
42
+ function resolveIncludeSrc(src: string): string {
43
+ if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:") || src.startsWith("/")) {
44
+ return src;
45
+ }
46
+ return staticFile(src);
47
+ }
48
+
49
+ /**
50
+ * Dynamic import of a component bundle, returning the registry map.
51
+ * Returns null if the URL is falsy or import fails.
52
+ */
53
+ function useSubVideoRegistry(importsUrl: string | undefined):
54
+ { registry: Record<string, React.ComponentType<any>> | null; loaded: boolean }
55
+ {
56
+ const [registry, setRegistry] = React.useState<Record<string, React.ComponentType<any>> | null>(null);
57
+ const [loaded, setLoaded] = React.useState(false);
58
+ const handleRef = React.useRef<number | null>(null);
59
+
60
+ React.useEffect(() => {
61
+ if (!importsUrl) {
62
+ setRegistry(null);
63
+ setLoaded(true);
64
+ return;
65
+ }
66
+ if (!handleRef.current) {
67
+ handleRef.current = delayRender(`Loading sub-video components: ${importsUrl}`);
68
+ }
69
+
70
+ import(/* webpackIgnore: true */ importsUrl)
71
+ .then((mod: any) => {
72
+ setRegistry(mod.default ?? mod);
73
+ setLoaded(true);
74
+ if (handleRef.current) {
75
+ continueRender(handleRef.current);
76
+ handleRef.current = null;
77
+ }
78
+ })
79
+ .catch((err: Error) => {
80
+ console.warn(`Sub-video component registry failed to load: ${importsUrl}`, err);
81
+ setLoaded(true);
82
+ if (handleRef.current) {
83
+ continueRender(handleRef.current);
84
+ handleRef.current = null;
85
+ }
86
+ });
87
+ }, [importsUrl]);
88
+
89
+ return { registry, loaded };
90
+ }
91
+
92
+ /**
93
+ * IncludeLeaf renders a video composition referenced by `src`.
94
+ *
95
+ * The `src` points to a JSON file that can be either:
96
+ * - A stream tree (has `type: "root"`) — rendered via FolderLeaf
97
+ * - A scene-based video.json (has `meta` and `scenes`) — rendered via inline scene renderer
98
+ *
99
+ * Falls back to inline `children` (legacy behavior) when `src` is not set.
100
+ *
101
+ * When the loaded JSON carries its own `imports` field (component bundle URL),
102
+ * IncludeLeaf dynamically imports the bundle and creates a nested ComposeContext
103
+ * with the sub-video's registry merged into the parent's, so custom components
104
+ * registered by the sub-video are available during its rendering.
105
+ *
106
+ * Usage:
107
+ * { type: "include", src: "./path/to/video.json", actions: [{ start: 0, end: 5 }] }
108
+ */
109
+ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
110
+ const { fps: parentFps, width: parentWidth, height: parentHeight } = useVideoConfig();
111
+ const parentCompose = React.useContext(ComposeContext);
112
+ const { Container } = parentCompose;
113
+ const parentAudio = React.useContext(AudioContext);
114
+ const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
115
+ useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * parentFps)));
116
+
117
+ if (!stream.actions?.length) return null;
118
+
119
+ // ── External JSON loading ─────────────────────────────────────────────
120
+ const [externalData, setExternalData] = React.useState<unknown | null>(null);
121
+ const [loadError, setLoadError] = React.useState<string | null>(null);
122
+ const [handle] = React.useState(() =>
123
+ stream.src ? delayRender(`Loading include: ${stream.src}`) : null,
124
+ );
125
+
126
+ React.useEffect(() => {
127
+ if (!stream.src || !handle) return;
128
+ let active = true;
129
+
130
+ const url = resolveIncludeSrc(stream.src);
131
+
132
+ fetch(url)
133
+ .then((res) => {
134
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${stream.src}`);
135
+ return res.json();
136
+ })
137
+ .then((data) => {
138
+ if (!active) return;
139
+ // Stamp durationInSeconds on loaded data so transitions work correctly
140
+ const streamTree = (data as any).root ?? data;
141
+ getDurationInSeconds(streamTree as unknown as DurationStream, true);
142
+ setExternalData(data);
143
+ continueRender(handle);
144
+ })
145
+ .catch((err: unknown) => {
146
+ if (!active) return;
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ setLoadError(msg);
149
+ console.warn(`Include "${stream.src}" failed to load: ${msg}`);
150
+ continueRender(handle);
151
+ });
152
+
153
+ return () => { active = false; };
154
+ }, [stream.src, handle]);
155
+
156
+ // Stamp durationInSeconds (for inline children legacy fallback)
157
+ React.useMemo(() => {
158
+ if (!stream.src) {
159
+ getDurationInSeconds(stream as unknown as DurationStream, true);
160
+ }
161
+ }, [stream, stream.src]);
162
+
163
+ // ── Extract sub-video imports URL from loaded data ─────────────────
164
+ const subImportsUrl = React.useMemo<string | undefined>(() => {
165
+ if (!externalData) return undefined;
166
+ const root = (externalData as any).root ?? externalData;
167
+ return root?.imports;
168
+ }, [externalData]);
169
+
170
+ // ── Load sub-video component registry ──────────────────────────────
171
+ const { registry: subRegistry, loaded: subRegistryLoaded } = useSubVideoRegistry(subImportsUrl);
172
+
173
+ // ── Merge registries: sub-video components take priority ───────────
174
+ const mergedCompose = React.useMemo(() => {
175
+ const parentComponents = parentCompose.components;
176
+ if (!subRegistry) return parentCompose;
177
+ return {
178
+ ...parentCompose,
179
+ components: {
180
+ ...parentComponents,
181
+ ...subRegistry,
182
+ },
183
+ };
184
+ }, [parentCompose, subRegistry]);
185
+
186
+ // ── Include foreground audio context ─────────────────────────────
187
+ const audioCtx = React.useMemo(
188
+ () => ({ id: stream.id, foreground: true, parent: parentAudio }),
189
+ [stream.id, parentAudio],
190
+ );
191
+
192
+ // ── External video content renderer ───────────────────────────────
193
+ const renderExternalContent = React.useCallback(() => {
194
+ if (!externalData) return null;
195
+ if (loadError) {
196
+ return (
197
+ <div style={{ color: "#ff4444", fontSize: 24, padding: 40 }}>
198
+ ⚠ Include load error: {loadError}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ if (isSceneBased(externalData)) {
204
+ // ── Scene-based video.json ──────────────────────────────────
205
+ const vj = externalData;
206
+ const vjFps = vj.meta.fps ?? parentFps;
207
+ // Use first aspect's dimensions (default to 16x9)
208
+ const aspectKey = (vj.meta.aspects?.[0] ?? "16x9") as keyof typeof ASPECT_DIMS;
209
+ const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
210
+
211
+ return (
212
+ <AbsoluteFill style={{ backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }}>
213
+ {/* Background music from video.json */}
214
+ {vj.bgm && (
215
+ <Audio src={vj.bgm.src} volume={vj.bgm.baseVolume} />
216
+ )}
217
+
218
+ {/* Per-scene rendering */}
219
+ {vj.scenes.map((scene: any) => {
220
+ const startFrame = Math.round((scene.start ?? 0) * vjFps);
221
+ const durFrames = Math.round((scene.duration ?? 1) * vjFps);
222
+ return (
223
+ <Sequence
224
+ key={scene.id}
225
+ from={startFrame}
226
+ durationInFrames={durFrames}
227
+ name={`${scene.id}:${scene.component}`}
228
+ >
229
+ {/* Scene headline + subhead */}
230
+ <AbsoluteFill
231
+ style={{
232
+ backgroundColor: "#0c0c0e",
233
+ display: "flex",
234
+ alignItems: "center",
235
+ justifyContent: "center",
236
+ padding: 60,
237
+ }}
238
+ >
239
+ <div
240
+ style={{
241
+ color: "#fafafa",
242
+ fontSize: 48,
243
+ fontWeight: 800,
244
+ fontFamily: "Inter, system-ui, sans-serif",
245
+ textAlign: "center",
246
+ marginBottom: 16,
247
+ }}
248
+ >
249
+ {scene.props?.headline ?? scene.id}
250
+ </div>
251
+ {scene.props?.subhead && (
252
+ <div
253
+ style={{
254
+ color: "#a1a1aa",
255
+ fontSize: 28,
256
+ fontFamily: "Inter, system-ui, sans-serif",
257
+ textAlign: "center",
258
+ }}
259
+ >
260
+ {scene.props.subhead}
261
+ </div>
262
+ )}
263
+ </AbsoluteFill>
264
+
265
+ {/* Voiceover audio */}
266
+ {scene.voiceover?.audio && (
267
+ <Audio src={scene.voiceover.audio} volume={1} />
268
+ )}
269
+ </Sequence>
270
+ );
271
+ })}
272
+ </AbsoluteFill>
273
+ );
274
+ }
275
+
276
+ // ── Stream tree format ─────────────────────────────────────────
277
+ const streamTree: Root = (externalData as any).root ?? externalData;
278
+ // Override width/height to parent-relative sizing
279
+ const merged = {
280
+ ...streamTree,
281
+ width: streamTree.width ?? parentWidth,
282
+ height: streamTree.height ?? parentHeight,
283
+ fps: streamTree.fps ?? parentFps,
284
+ };
285
+ return (
286
+ <div
287
+ style={{
288
+ width: merged.width,
289
+ height: merged.height,
290
+ overflow: "hidden",
291
+ position: "relative",
292
+ }}
293
+ >
294
+ <FolderLeaf stream={merged as any} />
295
+ </div>
296
+ );
297
+ }, [externalData, loadError, parentFps, parentWidth, parentHeight]);
298
+
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}
342
+ >
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>
371
+ );
372
+ };
373
+
374
+ // ── Determine if we need a nested ComposeContext ───────────────────
375
+ const needsNestedContext = subRegistry !== null && subRegistry !== parentCompose.components;
376
+
377
+ const inner = (
378
+ <AudioContext.Provider value={audioCtx as any}>
379
+ {stream.actions.map(renderAction)}
380
+ </AudioContext.Provider>
381
+ );
382
+
383
+ // When the sub-video has its own component registry, wrap in a nested
384
+ // ComposeContext so its components are available to FolderLeaf.
385
+ if (needsNestedContext && subRegistryLoaded) {
386
+ return (
387
+ <ComposeContext.Provider value={mergedCompose}>
388
+ {inner}
389
+ </ComposeContext.Provider>
390
+ );
391
+ }
392
+
393
+ return inner;
394
+ }