@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,1230 @@
1
+ import type {
2
+ Audio,
3
+ Component,
4
+ Effect,
5
+ EventSpec,
6
+ Folder,
7
+ Image,
8
+ Include,
9
+ MapStream,
10
+ Rhythm,
11
+ Root,
12
+ Scene,
13
+ Stream,
14
+ SubtitleOverlay,
15
+ Video,
16
+ } from "../schema/index";
17
+ import { uid, walkDown } from "../utils/index";
18
+
19
+ export interface CompileOptions {
20
+ defaults?: Partial<Record<"image" | "video" | "audio" | "component" | "rhythm" | "include" | "map", number>>;
21
+ }
22
+
23
+ /** A single effect spec — either a bare animation name or an object with options. */
24
+ export type EffectSpec = string | {
25
+ animation: string;
26
+ /** Override the animation duration (seconds). Defaults to the node's own duration. */
27
+ duration?: number;
28
+ animationTimingFunction?: "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out";
29
+ animationIterationCount?: number;
30
+ customKeyframes?: Record<string, Record<string, string>>;
31
+ };
32
+
33
+ export interface DescriptiveBaseNode {
34
+ id?: string;
35
+ instruction?: string;
36
+ style?: string;
37
+ visible?: boolean;
38
+ isBackground?: boolean;
39
+ duration?: number;
40
+ start?: number;
41
+ /** Effects applied to this node (e.g. `["fadeIn", {animation: "bounceIn", animationTimingFunction: "ease-out"}]`).
42
+ * Compiles into Effect wrapper streams at compile time, so the descriptive layer
43
+ * doesn't need explicit `effect` parent nodes. */
44
+ effects?: EffectSpec[];
45
+ /** Event that fires at a specific frame, mutating registered component state. */
46
+ on?: EventSpec;
47
+ }
48
+
49
+
50
+ export interface DescriptiveVideo extends DescriptiveBaseNode {
51
+ type: "video";
52
+ src?: string;
53
+ /** Text-to-video generation prompt. When set without src, triggers TTV pipeline. */
54
+ prompt?: string;
55
+ volume?: number;
56
+ playbackRate?: number;
57
+ width?: number;
58
+ height?: number;
59
+ startFrom?: number;
60
+ endAt?: number;
61
+ }
62
+
63
+ export interface DescriptiveAudio extends DescriptiveBaseNode {
64
+ type: "audio";
65
+ src?: string;
66
+ /** Narration text for TTS generation. When set without src, triggers TTS pipeline. */
67
+ script?: string;
68
+ volume?: number;
69
+ foreground?: boolean;
70
+ startFrom?: number;
71
+ endAt?: number;
72
+ loop?: number;
73
+ }
74
+
75
+ export interface DescriptiveImage extends DescriptiveBaseNode {
76
+ type: "image";
77
+ src?: string;
78
+ /** Text-to-image generation prompt. When set without src, triggers TTI pipeline. */
79
+ prompt?: string;
80
+ fit?: "contain" | "cover" | "fill";
81
+ }
82
+
83
+ export interface DescriptiveComponent extends DescriptiveBaseNode {
84
+ type: "component";
85
+ /** Inline JSX usage expression, compiled at runtime with frontmatter imports in scope.
86
+ * e.g. "<BarChart data={[{name:'A',value:80}]} />".
87
+ * Component tag names are resolved from root.imports. */
88
+ jsx: string;
89
+ }
90
+
91
+ /** Parsed component registration entry from a `~~~js imports` block.
92
+ * Remote imports have `from`; inline function defs only have `name`.
93
+ * The actual source text is preserved in the original importsBlock string. */
94
+ export interface ImportEntry {
95
+ /** Component name (used as JSX tag and lookup key). */
96
+ name: string;
97
+ /** Source spec: `npm:`, `git:`, `github:`, `https://`, or local path. */
98
+ from?: string;
99
+ /** Named export to pick from the module (default: "default"). */
100
+ exports?: string;
101
+ }
102
+
103
+ export interface DescriptiveRhythm extends DescriptiveBaseNode {
104
+ type: "rhythm";
105
+ src: string;
106
+ volume?: number;
107
+ spots?: number[];
108
+ children?: DescriptiveNode[];
109
+ }
110
+
111
+ export interface DescriptiveInclude extends DescriptiveBaseNode {
112
+ type: "include";
113
+ src?: string;
114
+ volume?: number;
115
+ children?: DescriptiveNode[];
116
+ }
117
+
118
+ export interface DescriptiveScene extends DescriptiveBaseNode {
119
+ type: "scene";
120
+ name?: string;
121
+ title?: string;
122
+ layout?: "series" | "parallel" | "transitionSeries";
123
+ transition?: string;
124
+ transitionTime?: number;
125
+ children: DescriptiveNode[];
126
+ }
127
+
128
+ export interface DescriptiveMapWaypoint {
129
+ lat: number;
130
+ lng: number;
131
+ label?: string;
132
+ media?: string;
133
+ }
134
+
135
+ export interface DescriptiveMap extends DescriptiveBaseNode {
136
+ type: "map";
137
+ waypoints: DescriptiveMapWaypoint[];
138
+ routeColor?: string;
139
+ routeWeight?: number;
140
+ zoom?: number;
141
+ center?: { lat: number; lng: number };
142
+ mapType?: "roadmap" | "satellite" | "hybrid" | "terrain";
143
+ travelMode?: "DRIVING" | "WALKING" | "BICYCLING" | "TRANSIT";
144
+ routeMarker?: string;
145
+ }
146
+
147
+ export interface DescriptiveContainer extends DescriptiveBaseNode {
148
+ type: "series" | "parallel" | "transitionSeries";
149
+ transition?: string;
150
+ transitionTime?: number;
151
+ children: DescriptiveNode[];
152
+ }
153
+
154
+ export type DescriptiveNode =
155
+ | DescriptiveVideo
156
+ | DescriptiveAudio
157
+ | DescriptiveImage
158
+ | DescriptiveComponent
159
+ | DescriptiveRhythm
160
+ | DescriptiveInclude
161
+ | DescriptiveScene
162
+ | DescriptiveMap
163
+ | DescriptiveContainer;
164
+
165
+ export interface DescriptiveRoot {
166
+ id?: "root";
167
+ width?: number;
168
+ height?: number;
169
+ fps?: number;
170
+ instruction?: string;
171
+ metadata?: string;
172
+ stylesheet?: string;
173
+ tts?: string;
174
+ stt?: string;
175
+ tti?: string;
176
+ ttv?: string;
177
+ layout?: "series" | "parallel" | "transitionSeries";
178
+ transition?: string;
179
+ transitionTime?: number;
180
+ /** Global subtitle overlay. src = VTT file with absolute timestamps. Set by resolveScripts pipeline. */
181
+ subtitle?: SubtitleOverlay;
182
+ /** @deprecated Unused — use importsBlock instead. DescriptiveRoot-level imports are no longer supported. */
183
+ imports?: never;
184
+ /** Raw imports block source (from \`\`\`imports code fence). Parsed by parseImportsBlock. */
185
+ importsBlock?: string;
186
+ children: DescriptiveNode[];
187
+ }
188
+
189
+ // ── Template variable resolution ─────────────────────────────────────────
190
+
191
+ export interface TemplateContext {
192
+ width: number;
193
+ height: number;
194
+ fps: number;
195
+ /** Variant name (e.g. "video", "zh", "portrait"). Defaults to "video". */
196
+ variant: string;
197
+ }
198
+
199
+ /**
200
+ * Resolve `${var}` placeholders in string values using the template context.
201
+ * Supports: width, height, fps, variant.
202
+ * Non-recursive — one pass, nested placeholders not supported.
203
+ */
204
+ export function resolveTemplateVars(value: string, ctx: TemplateContext): string {
205
+ return value.replace(/\$\{(\w+)\}/g, (_, name: string) => {
206
+ switch (name) {
207
+ case "width": return String(ctx.width);
208
+ case "height": return String(ctx.height);
209
+ case "fps": return String(ctx.fps);
210
+ case "variant": return ctx.variant;
211
+ default: return `\${${name}}`; // leave unresolved
212
+ }
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Walk the descriptive tree and resolve `${var}` placeholders in select
218
+ * content fields only: `src`, `prompt`, and `stylesheet`.
219
+ *
220
+ * Root config keys (width, height, fps, layout, tts, etc.) and other
221
+ * string fields (jsx, script, style, etc.) are NOT resolved — they are
222
+ * authored directly or configured by the engine.
223
+ *
224
+ * Called before compilation so each variant gets resolved values.
225
+ */
226
+ export function resolveAllTemplateVars(
227
+ root: DescriptiveRoot,
228
+ ctx: TemplateContext,
229
+ ): DescriptiveRoot {
230
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
231
+
232
+ // Resolve stylesheet at root level
233
+ if (typeof clone.stylesheet === "string") {
234
+ clone.stylesheet = resolveTemplateVars(clone.stylesheet, ctx);
235
+ }
236
+
237
+ // Resolve src + prompt on leaf nodes only
238
+ walkDown(clone as any, (node) => {
239
+ const n = node as any;
240
+ if (typeof n.src === "string") {
241
+ n.src = resolveTemplateVars(n.src, ctx);
242
+ }
243
+ if (typeof n.prompt === "string") {
244
+ n.prompt = resolveTemplateVars(n.prompt, ctx);
245
+ }
246
+ });
247
+
248
+ return clone;
249
+ }
250
+
251
+ interface CompileContext {
252
+ defaults: Record<"image" | "video" | "audio" | "component" | "rhythm" | "include" | "map" | "effect", number>;
253
+ }
254
+
255
+ interface CompileResult {
256
+ stream: Stream;
257
+ duration: number;
258
+ }
259
+
260
+ function isContainer(node: DescriptiveNode): node is DescriptiveContainer {
261
+ return node.type === "series" || node.type === "parallel" || node.type === "transitionSeries";
262
+ }
263
+
264
+ function isScene(node: DescriptiveNode): node is DescriptiveScene {
265
+ return node.type === "scene";
266
+ }
267
+
268
+ function isInclude(node: DescriptiveNode): node is DescriptiveInclude {
269
+ return node.type === "include";
270
+ }
271
+
272
+ function isMap(node: DescriptiveNode): node is DescriptiveMap {
273
+ return node.type === "map";
274
+ }
275
+
276
+ function isRhythm(node: DescriptiveNode): node is DescriptiveRhythm {
277
+ return node.type === "rhythm";
278
+ }
279
+
280
+ const DEFAULTS: CompileContext["defaults"] = {
281
+ image: 3,
282
+ video: 3,
283
+ audio: 3,
284
+ component: 2,
285
+ rhythm: 4,
286
+ include: 3,
287
+ map: 4,
288
+ effect: 2,
289
+ };
290
+
291
+ function ensureUniqueIds(children: DescriptiveNode[], scopeId: string): void {
292
+ const seen = new Set<string>();
293
+ for (const child of children) {
294
+ if (!child.id) continue;
295
+ if (seen.has(child.id)) {
296
+ console.warn(`duplicate id "${child.id}" in container "${scopeId}"`);
297
+ continue;
298
+ }
299
+ seen.add(child.id);
300
+ }
301
+ }
302
+
303
+ function deriveLeafDuration(node: DescriptiveNode, ctx: CompileContext): number {
304
+ if (typeof node.duration === "number" && node.duration > 0) {
305
+ return node.duration;
306
+ }
307
+
308
+ if ((node.type === "video" || node.type === "audio") && typeof node.endAt === "number") {
309
+ const startFrom = node.startFrom ?? 0;
310
+ const inferred = node.endAt - startFrom;
311
+ if (inferred > 0) return inferred;
312
+ }
313
+
314
+ const fallback = ctx.defaults[node.type as keyof CompileContext["defaults"]];
315
+ return fallback;
316
+ }
317
+
318
+ /** Normalize an EffectSpec to its object form. */
319
+ function normalizeEffectSpec(spec: EffectSpec): {
320
+ animation: string;
321
+ duration?: number;
322
+ animationTimingFunction?: "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out";
323
+ animationIterationCount?: number;
324
+ customKeyframes?: Record<string, Record<string, string>>;
325
+ } {
326
+ if (typeof spec === "string") {
327
+ const trimmed = spec.trim();
328
+ // Inline JSON object form: supports customKeyframes and other properties
329
+ // e.g. effects:[{animation:"custom", customKeyframes:{...}}]
330
+ if (trimmed.startsWith("{")) {
331
+ try {
332
+ return JSON.parse(trimmed);
333
+ } catch {
334
+ return { animation: trimmed };
335
+ }
336
+ }
337
+ // Parse optional positional params in parentheses.
338
+ // Syntax: fadeIn | fadeIn(1) | fadeIn(1,ease-in) | fadeIn(1, ease-in, 1)
339
+ // Order: (duration, timingFunction, iterationCount)
340
+ const parenIdx = spec.indexOf("(");
341
+ if (parenIdx === -1) {
342
+ return { animation: spec };
343
+ }
344
+ const animation = spec.slice(0, parenIdx).trim();
345
+ const body = spec.slice(parenIdx + 1, spec.lastIndexOf(")")).trim();
346
+ const result: ReturnType<typeof normalizeEffectSpec> = { animation };
347
+ const parts = body.split(",").map((p) => p.trim());
348
+
349
+ if (parts[0]) {
350
+ const n = Number(parts[0]);
351
+ if (Number.isFinite(n)) result.duration = n;
352
+ }
353
+ if (parts[1]) result.animationTimingFunction = parts[1] as any;
354
+ if (parts[2]) {
355
+ const n = Number(parts[2]);
356
+ if (Number.isFinite(n)) result.animationIterationCount = n;
357
+ }
358
+ return result;
359
+ }
360
+ return spec;
361
+ }
362
+
363
+ /** Extract `on` event from a descriptive node if present. */
364
+ function pickOn(node: { on?: EventSpec }): { on?: EventSpec } {
365
+ if (node.on) return { on: node.on };
366
+ return {};
367
+ }
368
+
369
+ /** Supported transition names for the compiled schema. */
370
+ const VALID_TRANSITIONS = new Set(["fade", "slide", "wipe", "flip", "clockWipe"]);
371
+
372
+ /**
373
+ * Parse a transition value that may include a parenthesized time, e.g.
374
+ * "fade" → { name: "fade", time: undefined }
375
+ * "fade(0.5)" → { name: "fade", time: 0.5 }
376
+ */
377
+ export function parseTransition(val: string | undefined): { name?: string; time?: number } {
378
+ if (!val) return { name: undefined, time: undefined };
379
+ const parenMatch = val.match(/^(\w+)\((\d+(?:\.\d+)?)\)$/);
380
+ if (parenMatch) {
381
+ return { name: parenMatch[1]!, time: Number(parenMatch[2]) };
382
+ }
383
+ return { name: val, time: undefined };
384
+ }
385
+
386
+ /**
387
+ * Resolve the effective transition name and time from a descriptive node.
388
+ * Supports inline "name(time)" format on `transition`, with `transitionTime`
389
+ * as fallback.
390
+ */
391
+ export function resolveTransition(
392
+ transition: string | undefined,
393
+ transitionTime: number | undefined,
394
+ ): { name: string; time: number } {
395
+ const parsed = parseTransition(transition);
396
+ const name = parsed.name ?? "fade";
397
+ // Separate transitionTime overrides inline time from "fade(0.5)" syntax
398
+ const inlineTime = parsed.time;
399
+ const time = transitionTime ?? inlineTime ?? 0.5;
400
+ // Ensure name is a valid compiled transition
401
+ if (!VALID_TRANSITIONS.has(name as any)) {
402
+ console.warn(`invalid transition "${name}", falling back to "fade"`);
403
+ return { name: "fade", time };
404
+ }
405
+ return { name, time };
406
+ }
407
+
408
+ /**
409
+ * Wrap a compiled stream inside Effect wrapper streams, applying the
410
+ * `effects` specs from the original descriptive node.
411
+ *
412
+ * The innermost effect wraps the leaf; the outermost effect carries
413
+ * the leaf's absolute timing (for correct placement in the parent timeline).
414
+ * Each effect's children have relative (start=0) timing.
415
+ */
416
+ function wrapWithEffects(
417
+ node: { effects?: EffectSpec[] },
418
+ result: CompileResult,
419
+ parentKind: "series" | "parallel" | "transitionSeries",
420
+ ): CompileResult {
421
+ const rawEffects = node.effects;
422
+ if (!rawEffects || rawEffects.length === 0) return result;
423
+
424
+ const effects = rawEffects.map(normalizeEffectSpec);
425
+ const innerStream = result.stream;
426
+ const innerActions = (innerStream as any).actions ?? [];
427
+ const firstAction = innerActions[0] ?? {};
428
+ const absStart = firstAction.start ?? 0;
429
+ const absEnd = firstAction.end ?? result.duration;
430
+ const duration = absEnd - absStart;
431
+
432
+ // Reset inner stream's actions to be relative (start=0) so the effect
433
+ // wrapper owns the absolute timing. The EffectWrapper renders children
434
+ // with their relative actions inside its own Sequence.
435
+ const resetStream = {
436
+ ...innerStream,
437
+ actions: [{
438
+ ...firstAction,
439
+ id: uid(),
440
+ start: 0,
441
+ end: duration,
442
+ }],
443
+ durationInSeconds: duration,
444
+ } as any;
445
+
446
+ // Build nested effect wrappers from innermost → outermost.
447
+ // The outermost effect uses the original absolute timing;
448
+ // inner effects (for multiple effects) are relative to their parent effect.
449
+ let currentStream = resetStream;
450
+ for (let i = effects.length - 1; i >= 0; i--) {
451
+ const spec = effects[i]!;
452
+ const isOutermost = i === effects.length - 1;
453
+ const effStart = isOutermost ? absStart : 0;
454
+ // If spec has explicit duration, use it instead of the leaf's full duration
455
+ const specDuration = spec.duration ?? (isOutermost ? duration : duration);
456
+ const effEnd = isOutermost
457
+ ? (spec.duration != null ? effStart + spec.duration : absEnd)
458
+ : specDuration;
459
+
460
+ currentStream = {
461
+ id: uid(),
462
+ type: "effect",
463
+ animation: spec.animation,
464
+ durationInSeconds: spec.duration,
465
+ animationTimingFunction: spec.animationTimingFunction,
466
+ animationIterationCount: spec.animationIterationCount ?? 1,
467
+ customKeyframes: spec.customKeyframes,
468
+ children: [currentStream],
469
+ actions: [{
470
+ id: uid(),
471
+ start: effStart,
472
+ end: effEnd,
473
+ }],
474
+ visible: true,
475
+ ...pickOn(node),
476
+ } as Effect;
477
+ }
478
+
479
+ return { stream: currentStream, duration: result.duration };
480
+ }
481
+
482
+ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | DescriptiveScene | DescriptiveInclude>, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
483
+ const id = node.id ?? uid();
484
+ const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
485
+ const duration = deriveLeafDuration(node, ctx);
486
+ const end = start + duration;
487
+
488
+ const base = {
489
+ id,
490
+ style: node.style,
491
+ visible: node.visible ?? true,
492
+ isBackground: node.isBackground,
493
+ durationInSeconds: end,
494
+ ...pickOn(node),
495
+ };
496
+
497
+ const action = {
498
+ id: uid(),
499
+ start,
500
+ end,
501
+ startFrom: node.type === "video" || node.type === "audio" ? node.startFrom : undefined,
502
+ endAt: node.type === "video" || node.type === "audio" ? node.endAt : undefined,
503
+ loop: node.type === "audio" ? node.loop : undefined,
504
+ volume:
505
+ node.type === "video" || node.type === "audio" || node.type === "rhythm"
506
+ ? node.volume
507
+ : undefined,
508
+ };
509
+
510
+ switch (node.type) {
511
+ case "video": {
512
+ const stream: Video = {
513
+ ...base,
514
+ type: "video",
515
+ src: node.src,
516
+ volume: node.volume ?? 1,
517
+ playbackRate: node.playbackRate,
518
+ width: node.width ?? 1080,
519
+ height: node.height ?? 1920,
520
+ actions: [action],
521
+ };
522
+ return { stream, duration: end };
523
+ }
524
+ case "audio": {
525
+ const stream: Audio = {
526
+ ...base,
527
+ type: "audio",
528
+ src: node.src,
529
+ volume: node.volume ?? 1,
530
+ foreground: node.foreground,
531
+ actions: [action],
532
+ };
533
+ return { stream, duration: end };
534
+ }
535
+ case "image": {
536
+ const stream: Image = {
537
+ ...base,
538
+ type: "image",
539
+ src: node.src,
540
+ fit: node.fit ?? "contain",
541
+ actions: [action],
542
+ };
543
+ return { stream, duration: end };
544
+ }
545
+ case "component": {
546
+ // Collect extra properties from descriptive node (e.g. `source` from ~~~md source fences)
547
+ const KNOWN_COMPONENT_KEYS = new Set([
548
+ "type", "jsx", "id", "instruction", "style", "visible",
549
+ "isBackground", "duration", "start", "on",
550
+ ]);
551
+ const bindings: Record<string, string> = {};
552
+ for (const key of Object.keys(node)) {
553
+ if (!KNOWN_COMPONENT_KEYS.has(key)) {
554
+ const val = (node as any)[key];
555
+ if (typeof val === "string") bindings[key] = val;
556
+ }
557
+ }
558
+
559
+ const stream: Component = {
560
+ ...base,
561
+ type: "component",
562
+ jsx: node.jsx,
563
+ data: Object.keys(bindings).length ? bindings : undefined,
564
+ actions: [action],
565
+ };
566
+ return { stream, duration: end };
567
+ }
568
+ case "rhythm": {
569
+ // rhythm without children compiles as a plain audio leaf
570
+ const stream: Rhythm = {
571
+ ...base,
572
+ type: "rhythm",
573
+ src: node.src,
574
+ volume: node.volume ?? 1,
575
+ spots: node.spots,
576
+ children: [],
577
+ actions: [action],
578
+ };
579
+ return { stream, duration: end };
580
+ }
581
+ case "map": {
582
+ const stream: MapStream = {
583
+ ...base,
584
+ type: "map",
585
+ waypoints: node.waypoints,
586
+ routeColor: node.routeColor ?? "#4285F4",
587
+ routeWeight: node.routeWeight ?? 4,
588
+ zoom: node.zoom ?? 10,
589
+ center: node.center,
590
+ mapType: node.mapType ?? "roadmap",
591
+ travelMode: node.travelMode ?? "DRIVING",
592
+ routeMarker: node.routeMarker ?? "🚗",
593
+ actions: [action],
594
+ };
595
+ return { stream, duration: end };
596
+ }
597
+ }
598
+ }
599
+
600
+ function compileChildren(
601
+ children: DescriptiveNode[],
602
+ ctx: CompileContext,
603
+ parentKind: "series" | "parallel" | "transitionSeries",
604
+ ): CompileResult[] {
605
+ return children
606
+ .filter((child) => child.visible !== false)
607
+ .map((child) => {
608
+ let result: CompileResult;
609
+ if (isContainer(child)) {
610
+ result = compileContainer(child, ctx, parentKind);
611
+ } else if (isScene(child)) {
612
+ result = compileScene(child, ctx, parentKind);
613
+ } else if (isInclude(child)) {
614
+ result = compileInclude(child, ctx, parentKind);
615
+ } else if (isRhythm(child)) {
616
+ result = compileRhythm(child, ctx, parentKind);
617
+ } else if (isMap(child)) {
618
+ result = compileLeaf(child, ctx, parentKind);
619
+ } else {
620
+ result = compileLeaf(child, ctx, parentKind);
621
+ }
622
+ // Apply direct effects on any node (descriptive-layer shorthand).
623
+ result = wrapWithEffects(child, result, parentKind);
624
+ return result;
625
+ });
626
+ }
627
+
628
+ function aggregateDuration(
629
+ children: CompileResult[],
630
+ kind: "series" | "parallel" | "transitionSeries",
631
+ transitionTime?: number,
632
+ ): number {
633
+ if (kind === "parallel") {
634
+ let max = 0;
635
+ for (const child of children) {
636
+ if ((child.stream as any).isBackground) continue;
637
+ max = Math.max(max, child.duration);
638
+ }
639
+ return max;
640
+ }
641
+
642
+ let total = 0;
643
+ const overlap = kind === "transitionSeries" ? (transitionTime ?? 0.5) : 0;
644
+ for (let i = 0; i < children.length; i++) {
645
+ if ((children[i]!.stream as any).isBackground) continue;
646
+ total += children[i]!.duration;
647
+ if (i > 0 && overlap > 0) total -= overlap;
648
+ }
649
+ return total;
650
+ }
651
+
652
+ function compileScene(
653
+ node: DescriptiveScene,
654
+ ctx: CompileContext,
655
+ parentKind: "series" | "parallel" | "transitionSeries",
656
+ ): CompileResult {
657
+ const id = node.id ?? uid();
658
+ ensureUniqueIds(node.children, id);
659
+
660
+ const sceneKind = node.layout ?? "parallel";
661
+ const resolved = resolveTransition(node.transition, node.transitionTime);
662
+ const compiledChildren = compileChildren(node.children, ctx, sceneKind);
663
+ const sceneChildren = sceneKind === "parallel"
664
+ ? compiledChildren.map((c) => c.stream)
665
+ : [
666
+ {
667
+ id: `${id}-layout`,
668
+ type: "folder",
669
+ visible: true,
670
+ isSeries: true,
671
+ transition: sceneKind === "transitionSeries" ? resolved.name : undefined,
672
+ transitionTime: sceneKind === "transitionSeries" ? resolved.time : 0.5,
673
+ children: compiledChildren.map((c) => c.stream),
674
+ durationInSeconds: aggregateDuration(compiledChildren, sceneKind, resolved.time),
675
+ } as Folder,
676
+ ];
677
+
678
+ const sceneContentDuration = sceneKind === "parallel"
679
+ ? aggregateDuration(compiledChildren, "parallel")
680
+ : aggregateDuration(compiledChildren, sceneKind, resolved.time);
681
+ const localDuration = Math.max(node.duration ?? 0, sceneContentDuration);
682
+ const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
683
+ const end = start + localDuration;
684
+
685
+ const stream: Scene = {
686
+ id,
687
+ type: "scene",
688
+ name: node.name,
689
+ title: node.title,
690
+ instruction: node.instruction,
691
+ style: node.style,
692
+ visible: node.visible ?? true,
693
+ isBackground: node.isBackground,
694
+ children: sceneChildren,
695
+ durationInSeconds: end,
696
+ ...pickOn(node),
697
+ };
698
+
699
+ return { stream, duration: end };
700
+ }
701
+
702
+ function compileInclude(
703
+ node: DescriptiveInclude,
704
+ ctx: CompileContext,
705
+ parentKind: "series" | "parallel" | "transitionSeries",
706
+ ): CompileResult {
707
+ const id = node.id ?? uid();
708
+ const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
709
+
710
+ const compiledChildren = node.children?.length ? compileChildren(node.children, ctx, "parallel") : [];
711
+ const childrenDuration = aggregateDuration(compiledChildren, "parallel");
712
+
713
+ let duration = node.duration ?? 0;
714
+ if (!duration && node.src) {
715
+ duration = ctx.defaults.include;
716
+ }
717
+ if (!duration) {
718
+ duration = childrenDuration;
719
+ }
720
+ if (!duration) {
721
+ duration = ctx.defaults.include;
722
+ }
723
+
724
+ const end = start + duration;
725
+
726
+ const stream: Include = {
727
+ id,
728
+ type: "include",
729
+ style: node.style,
730
+ visible: node.visible ?? true,
731
+ isBackground: node.isBackground,
732
+ src: node.src,
733
+ volume: node.volume ?? 1,
734
+ children: compiledChildren.map((c) => c.stream),
735
+ actions: [
736
+ {
737
+ id: uid(),
738
+ start,
739
+ end,
740
+ },
741
+ ],
742
+ durationInSeconds: end,
743
+ ...pickOn(node),
744
+ };
745
+
746
+ return { stream, duration: end };
747
+ }
748
+
749
+ function compileRhythm(
750
+ node: DescriptiveRhythm,
751
+ ctx: CompileContext,
752
+ parentKind: "series" | "parallel" | "transitionSeries",
753
+ ): CompileResult {
754
+ const id = node.id ?? uid();
755
+ const spots = node.spots ?? [];
756
+ const children = node.children ?? [];
757
+
758
+ // Duration is derived from spots: last beat + average gap covers last child
759
+ const avgGap = spots.length > 1
760
+ ? (spots[spots.length - 1]! - spots[0]!) / (spots.length - 1)
761
+ : 0;
762
+ const rhythmDuration = spots.length ? spots[spots.length - 1]! + avgGap : 0;
763
+
764
+ // Distribute children across beats: child[i] starts at spots[i], ends at spots[i+1] (or last beat + avg gap)
765
+ const compiledChildren: Stream[] = [];
766
+ if (children.length && spots.length) {
767
+ const avgGap = spots.length > 1
768
+ ? (spots[spots.length - 1]! - spots[0]!) / (spots.length - 1)
769
+ : 1;
770
+ for (let i = 0; i < children.length; i++) {
771
+ const beatStart = spots[Math.min(i, spots.length - 1)] ?? 0;
772
+ const beatEnd = i < spots.length - 1
773
+ ? spots[i + 1]!
774
+ : beatStart + avgGap;
775
+ const beatDur = Math.max(0.1, beatEnd - beatStart);
776
+
777
+ // Inject beat timing into child via duration override
778
+ const childWithTiming = { ...children[i]!, start: beatStart, duration: beatDur } as DescriptiveNode;
779
+ const compiled = isContainer(childWithTiming)
780
+ ? compileContainer(childWithTiming, ctx, "parallel")
781
+ : isScene(childWithTiming)
782
+ ? compileScene(childWithTiming, ctx, "parallel")
783
+ : isInclude(childWithTiming)
784
+ ? compileInclude(childWithTiming, ctx, "parallel")
785
+ : isRhythm(childWithTiming)
786
+ ? compileRhythm(childWithTiming, ctx, "parallel")
787
+ : compileLeaf(childWithTiming, ctx, "parallel");
788
+ compiledChildren.push(compiled.stream);
789
+ }
790
+ }
791
+
792
+ const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
793
+ const end = start + rhythmDuration;
794
+
795
+ const stream: Rhythm = {
796
+ id,
797
+ type: "rhythm",
798
+ style: node.style,
799
+ visible: node.visible ?? true,
800
+ isBackground: node.isBackground,
801
+ src: node.src,
802
+ volume: node.volume ?? 1,
803
+ spots: node.spots,
804
+ children: compiledChildren,
805
+ actions: [
806
+ {
807
+ id: uid(),
808
+ start,
809
+ end,
810
+ volume: node.volume,
811
+ },
812
+ ],
813
+ durationInSeconds: end,
814
+ ...pickOn(node),
815
+ };
816
+
817
+ return { stream, duration: end };
818
+ }
819
+
820
+ function compileContainer(node: DescriptiveContainer, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
821
+ const id = node.id ?? uid();
822
+ ensureUniqueIds(node.children, id);
823
+
824
+ const resolved = node.type === "transitionSeries"
825
+ ? resolveTransition(node.transition, node.transitionTime)
826
+ : { name: "fade", time: 0.5 };
827
+ const children = compileChildren(node.children, ctx, node.type);
828
+ const duration = aggregateDuration(children, node.type, resolved.time);
829
+
830
+ const stream: Folder = {
831
+ id,
832
+ type: "folder",
833
+ style: node.style,
834
+ visible: node.visible ?? true,
835
+ isBackground: node.isBackground,
836
+ isSeries: node.type !== "parallel",
837
+ transition: node.type === "transitionSeries" ? resolved.name : undefined,
838
+ transitionTime: node.type === "transitionSeries" ? resolved.time : 0.5,
839
+ children: children.map((c) => c.stream),
840
+ durationInSeconds: duration,
841
+ ...pickOn(node),
842
+ };
843
+
844
+ return { stream, duration };
845
+ }
846
+
847
+ // ── Frontmatter imports resolver ──────────────────────────────────────────
848
+
849
+ /**
850
+ * Parse an \`\`\`imports code block into ImportEntry[].
851
+ *
852
+ * Supports:
853
+ * export { Name } from "spec" — remote import
854
+ * export { Name as Alias } from "spec" — aliased remote import
855
+ * export { Name1, Name2 } from "spec" — multiple from same source
856
+ * export function Name(...) { ... } — inline component definition
857
+ * export default function Name(...) { ... } — inline default definition
858
+ * import "spec" — side-effect import (no component registered)
859
+ */
860
+
861
+ export function parseImportsBlock(source: string): ImportEntry[] {
862
+ const entries: ImportEntry[] = [];
863
+ // Track names brought into scope by import statements, so bare `export { Name }` can resolve them
864
+ const importedNames = new Map<string, { from: string; exports: string }>();
865
+ const lines = source.split("\n");
866
+ let i = 0;
867
+
868
+ while (i < lines.length) {
869
+ const line = lines[i]!.trim();
870
+
871
+ // import { Name } from "spec" — internal dep, tracks scope but doesn't register
872
+ const namedImport = /^import\s+\{\s*([^}]+)\}\s+from\s+["'`](.+?)["'`]\s*;?\s*$/.exec(line);
873
+ if (namedImport) {
874
+ const namesStr = namedImport[1]!;
875
+ const fromSpec = namedImport[2]!;
876
+ for (const part of namesStr.split(",")) {
877
+ const trimmed = part.trim();
878
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(trimmed);
879
+ const exportName = asMatch ? asMatch[1]!.trim() : trimmed;
880
+ const name = asMatch ? asMatch[2]!.trim() : trimmed;
881
+ importedNames.set(name, { from: fromSpec, exports: exportName });
882
+ }
883
+ i++;
884
+ continue;
885
+ }
886
+
887
+ // import DefaultName from "spec" — internal dep, tracks scope but doesn't register
888
+ const defaultImport = /^import\s+(\w+)\s+from\s+["'`](.+?)["'`]\s*;?\s*$/.exec(line);
889
+ if (defaultImport) {
890
+ importedNames.set(defaultImport[1]!, { from: defaultImport[2]!, exports: "default" });
891
+ i++;
892
+ continue;
893
+ }
894
+
895
+ // import "spec" — side-effect import, no component registered
896
+ const sideEffectImport = /^import\s+["'`](.+?)["'`]\s*;?\s*$/.exec(line);
897
+ if (sideEffectImport) {
898
+ // No component to register, just ensure the spec is extracted by extractDependencySpecs
899
+ i++;
900
+ continue;
901
+ }
902
+
903
+ // export { Name } from "spec" (re-export with explicit source, registers Name)
904
+ // export { Name as Alias } from "spec"
905
+ // export { Name1, Name2 } from "spec"
906
+ const namedReExport = /^export\s+\{\s*([^}]+)\}\s+from\s+["'`](.+?)["'`]\s*;?\s*$/.exec(line);
907
+ if (namedReExport) {
908
+ const namesStr = namedReExport[1]!;
909
+ const fromSpec = namedReExport[2]!;
910
+ for (const part of namesStr.split(",")) {
911
+ const trimmed = part.trim();
912
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(trimmed);
913
+ const exportName = asMatch ? asMatch[1]!.trim() : trimmed;
914
+ const name = asMatch ? asMatch[2]!.trim() : trimmed;
915
+ entries.push({ name, from: fromSpec, exports: exportName });
916
+ }
917
+ i++;
918
+ continue;
919
+ }
920
+
921
+ // export { Name } (bare re-export — resolves from importedNames)
922
+ // export { Name as Alias }
923
+ // export { Name1, Name2 }
924
+ const bareReExport = /^export\s+\{\s*([^}]+)\}\s*;?\s*$/.exec(line);
925
+ if (bareReExport) {
926
+ const namesStr = bareReExport[1]!;
927
+ for (const part of namesStr.split(",")) {
928
+ const trimmed = part.trim();
929
+ const asMatch = /^(.+?)\s+as\s+(.+)$/i.exec(trimmed);
930
+ const exportName = asMatch ? asMatch[1]!.trim() : trimmed;
931
+ const name = asMatch ? asMatch[2]!.trim() : trimmed;
932
+ // Look up the source from imported names
933
+ const imported = importedNames.get(exportName);
934
+ if (imported) {
935
+ entries.push({ name, from: imported.from, exports: imported.exports });
936
+ }
937
+ }
938
+ i++;
939
+ continue;
940
+ }
941
+
942
+ // export default Name from "spec" (default re-export, registers Name)
943
+ const defaultReExport = /^export\s+default\s+(\w+)\s+from\s+["'`](.+?)["'`]\s*;?\s*$/.exec(line);
944
+ if (defaultReExport) {
945
+ entries.push({ name: defaultReExport[1]!, from: defaultReExport[2]!, exports: "default" });
946
+ i++;
947
+ continue;
948
+ }
949
+
950
+ // export function Name(...) { ... } or export default function Name(...) { ... }
951
+ // Only the name is extracted for validation — the raw source is used by the bundler.
952
+ const funcExport = /^export(?:\s+default)?\s+function\s+(\w+)\s*\(/.exec(line);
953
+ if (funcExport) {
954
+ entries.push({ name: funcExport[1]! });
955
+ // Skip the function body (braces tracked)
956
+ let braceDepth = (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
957
+ i++;
958
+ while (i < lines.length && braceDepth > 0) {
959
+ const bl = lines[i]!;
960
+ braceDepth += (bl.match(/{/g) || []).length;
961
+ braceDepth -= (bl.match(/}/g) || []).length;
962
+ i++;
963
+ }
964
+ continue;
965
+ }
966
+
967
+ i++;
968
+ }
969
+
970
+ return entries;
971
+ }
972
+
973
+ /**
974
+ * Extract all dependency package specifiers from an imports block.
975
+ * This includes:
976
+ * - `export { X } from "spec"` and `import { X } from "spec"` (named re-exports / named imports)
977
+ * - `import "spec"` (side-effect-only imports)
978
+ *
979
+ * Used by the bundler to populate package.json.
980
+ *
981
+ * Returns the raw spec strings (e.g. "npm:recharts", "react", "npm:@remotion/tailwind-v4").
982
+ */
983
+ export function extractDependencySpecs(source: string): string[] {
984
+ const specs: string[] = [];
985
+ // Match `from "spec"` in export/import statements
986
+ const fromRe = /from\s+["'`](.+?)["'`]\s*;?\s*$/gm;
987
+ let m;
988
+ while ((m = fromRe.exec(source)) !== null) {
989
+ specs.push(m[1]);
990
+ }
991
+ // Match bare side-effect imports: `import "spec"`
992
+ for (const line of source.split("\n")) {
993
+ const trimmed = line.trim();
994
+ const sideEffectMatch = /^import\s+["'`](.+?)["'`]\s*;?\s*$/.exec(trimmed);
995
+ if (sideEffectMatch) {
996
+ specs.push(sideEffectMatch[1]);
997
+ }
998
+ }
999
+ return specs;
1000
+ }
1001
+ function resolveComponentSources(root: DescriptiveRoot): Set<string> {
1002
+ if (!root.importsBlock) return new Set();
1003
+ const entries = parseImportsBlock(root.importsBlock);
1004
+ return new Set(entries.map((e) => e.name).filter(Boolean));
1005
+ }
1006
+
1007
+ /**
1008
+ * Walk the descriptive tree and warn about JSX component tags
1009
+ * that aren't registered in the imports registry.
1010
+ */
1011
+ function warnUnregisteredComponents(root: DescriptiveRoot, registeredNames: Set<string>): void {
1012
+
1013
+ const tagRe = /<\s*\/?\s*([A-Z][a-zA-Z0-9]*)/g;
1014
+ const visit = (node: DescriptiveNode): void => {
1015
+ if (node.type === "component" && node.jsx) {
1016
+ const found = new Set<string>();
1017
+ let m;
1018
+ tagRe.lastIndex = 0;
1019
+ while ((m = tagRe.exec(node.jsx)) !== null) {
1020
+ const tag = m[1]!;
1021
+ // Only warn for uppercase tags (component references)
1022
+ // Lowercase tags are HTML/SVG built-ins.
1023
+ if (!registeredNames.has(tag)) {
1024
+ found.add(tag);
1025
+ }
1026
+ }
1027
+ if (found.size > 0) {
1028
+ console.warn(` ⚠ ${[...found].sort().join(", ")}: missing from imports`);
1029
+ }
1030
+ }
1031
+ const children = (node as { children?: DescriptiveNode[] }).children;
1032
+ if (Array.isArray(children)) {
1033
+ for (const c of children) visit(c);
1034
+ }
1035
+ };
1036
+ for (const c of root.children) visit(c);
1037
+ }
1038
+
1039
+ // ── Variant override resolution ─────────────────────────────────────────
1040
+
1041
+ /**
1042
+ * Collect all variant names from the entire tree by scanning every node's keys.
1043
+ * Detects keys like `zh-src`, `zh-tiktok-style`, etc.
1044
+ */
1045
+ function collectVariantNames(root: Record<string, unknown>, variantChain: string[]): Set<string> {
1046
+ const names = new Set<string>();
1047
+
1048
+ function scan(node: Record<string, unknown>): void {
1049
+ for (const key of Object.keys(node)) {
1050
+ const idx = key.indexOf("-");
1051
+ if (idx > 0) {
1052
+ const candidate = key.slice(0, idx);
1053
+ // Only collect if it matches a variant in the chain or is a known variant prefix
1054
+ if (
1055
+ candidate.length > 0 &&
1056
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(candidate) &&
1057
+ !names.has(candidate)
1058
+ ) {
1059
+ // Check if this prefix appears on a key that has a corresponding base key
1060
+ const baseKey = key.slice(idx + 1);
1061
+ if (baseKey in node || variantChain.includes(candidate)) {
1062
+ names.add(candidate);
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ // Recurse
1068
+ const children = node.children;
1069
+ if (Array.isArray(children)) {
1070
+ for (const child of children) scan(child as Record<string, unknown>);
1071
+ }
1072
+ }
1073
+
1074
+ scan(root);
1075
+ return names;
1076
+ }
1077
+
1078
+ /**
1079
+ * For a given field name and variant chain, check if a variant-specific
1080
+ * override exists on the node. Returns the override value if found, else undefined.
1081
+ *
1082
+ * Lookup order (first match wins):
1083
+ * 1. <variant1>-<variant2>-<key> (most specific combined)
1084
+ * 2. <variant1>-<key>
1085
+ * 3. <variant2>-<key>
1086
+ * 4. <key> (base — caller handles this)
1087
+ */
1088
+ function lookupVariantValue(
1089
+ node: Record<string, unknown>,
1090
+ key: string,
1091
+ variantChain: string[],
1092
+ ): unknown | undefined {
1093
+ // Combined variant key: zh-tiktok-src
1094
+ if (variantChain.length > 1) {
1095
+ const combinedKey = `${variantChain.join("-")}-${key}`;
1096
+ if (combinedKey in node) return node[combinedKey];
1097
+ }
1098
+ // Individual variant keys: zh-src, then tiktok-src
1099
+ for (const v of variantChain) {
1100
+ const vKey = `${v}-${key}`;
1101
+ if (vKey in node) return node[vKey];
1102
+ }
1103
+ return undefined;
1104
+ }
1105
+
1106
+ /**
1107
+ * Map of node type → its "primary content" key.
1108
+ * When a bare variant key like `zh` is found, its value replaces this key.
1109
+ */
1110
+ const PRIMARY_CONTENT_KEY: Record<string, string> = {
1111
+ audio: "script",
1112
+ component: "jsx",
1113
+ image: "src",
1114
+ video: "src",
1115
+ };
1116
+
1117
+ /**
1118
+ * Resolve variant-specific overrides on a deep-cloned copy of the descriptive root.
1119
+ *
1120
+ * Two override mechanisms:
1121
+ * 1. **Variant-prefixed keys**: `zh-src` → `src` for variant "zh"
1122
+ * 2. **Bare variant keys**: `zh` → replaces the node's "primary content" key
1123
+ * (e.g., `zh:"欢迎"` → replaces `script` on audio nodes, `jsx` on components)
1124
+ *
1125
+ * Then strips all `<variant>-*` keys from the output.
1126
+ *
1127
+ * @param root - The base descriptive root (from `# video`)
1128
+ * @param variantChain - Ordered variant names (e.g. ["zh", "tiktok"])
1129
+ * @returns A new DescriptiveRoot with variant overrides applied
1130
+ */
1131
+ export function resolveVariantOverrides(
1132
+ root: DescriptiveRoot,
1133
+ variantChain: string[],
1134
+ ): DescriptiveRoot {
1135
+ if (variantChain.length === 0) return root;
1136
+
1137
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
1138
+
1139
+ // Scan entire tree for variant-prefixed keys to strip later
1140
+ const knownVariants = collectVariantNames(clone as Record<string, unknown>, variantChain);
1141
+
1142
+ function applyOverrides(node: Record<string, unknown>): void {
1143
+ // Phase 1: variant-prefixed overrides (zh-src → src)
1144
+ const keys = Object.keys(node);
1145
+ for (const key of keys) {
1146
+ if (key === "type" || key === "id" || key === "children") continue;
1147
+ const override = lookupVariantValue(node, key, variantChain);
1148
+ if (override !== undefined) {
1149
+ node[key] = override;
1150
+ }
1151
+ }
1152
+
1153
+ // Phase 2: bare variant keys (zh → replaces primary content key)
1154
+ const nodeType = node.type as string | undefined;
1155
+ const primaryKey = nodeType ? PRIMARY_CONTENT_KEY[nodeType] : undefined;
1156
+ if (primaryKey) {
1157
+ for (const v of variantChain) {
1158
+ if (v in node) {
1159
+ node[primaryKey] = node[v];
1160
+ break; // first matching variant wins
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ // Strip all <variant>-* keys AND bare variant keys from the output
1166
+ const stripKeys = new Set<string>(variantChain);
1167
+ for (const v of knownVariants) {
1168
+ const prefix = `${v}-`;
1169
+ for (const key of Object.keys(node)) {
1170
+ if (key.startsWith(prefix) || stripKeys.has(key)) {
1171
+ delete node[key];
1172
+ }
1173
+ }
1174
+ }
1175
+ // Also strip bare variant keys that aren't covered by knownVariants
1176
+ for (const v of variantChain) {
1177
+ delete node[v];
1178
+ }
1179
+
1180
+ // Recurse into children
1181
+ const children = node.children;
1182
+ if (Array.isArray(children)) {
1183
+ for (const child of children) {
1184
+ applyOverrides(child as Record<string, unknown>);
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ applyOverrides(clone as Record<string, unknown>);
1190
+ return clone;
1191
+ }
1192
+
1193
+ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileOptions = {}): Root {
1194
+ const ctx: CompileContext = {
1195
+ defaults: {
1196
+ ...DEFAULTS,
1197
+ ...(options.defaults ?? {}),
1198
+ },
1199
+ };
1200
+
1201
+ // Resolve frontmatter imports / inline component defs onto each component node.
1202
+ const registry = resolveComponentSources(input);
1203
+ warnUnregisteredComponents(input, registry);
1204
+
1205
+ ensureUniqueIds(input.children, "root");
1206
+
1207
+ const rootKind = input.layout ?? "series";
1208
+ const resolved = resolveTransition(input.transition, input.transitionTime);
1209
+ const children = compileChildren(input.children, ctx, rootKind);
1210
+ const duration = aggregateDuration(children, rootKind, resolved.time);
1211
+
1212
+ const compiled: Root = {
1213
+ id: "root",
1214
+ type: "root",
1215
+ visible: true,
1216
+ width: input.width ?? 1080,
1217
+ height: input.height ?? 1920,
1218
+ fps: input.fps ?? 30,
1219
+ instruction: input.instruction,
1220
+ metadata: input.metadata,
1221
+ stylesheet: input.stylesheet,
1222
+ subtitle: input.subtitle,
1223
+ isSeries: rootKind !== "parallel",
1224
+ transition: rootKind === "transitionSeries" ? resolved.name : undefined,
1225
+ transitionTime: rootKind === "transitionSeries" ? resolved.time : 0.5,
1226
+ children: children.map((c) => c.stream),
1227
+ durationInSeconds: duration,
1228
+ };
1229
+ return compiled;
1230
+ }