@lalalic/markcut 2.5.1 โ†’ 2.7.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.
@@ -490,19 +490,27 @@ function wrapWithEffects(
490
490
 
491
491
  function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | DescriptiveScene | DescriptiveInclude>, ctx: CompileContext, parentKind: "series" | "parallel" | "transitionSeries"): CompileResult {
492
492
  const id = node.id ?? uid();
493
- const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
494
- const duration = deriveLeafDuration(node, ctx);
495
- const end = start + duration;
493
+
494
+ // Background nodes without explicit duration/endAt: let parent fill timing later
495
+ const hasOwnDuration = typeof node.duration === "number" || typeof node.endAt === "number";
496
+ const isBgNoOwnTiming = node.isBackground && !hasOwnDuration;
497
+
498
+ // For background without own timing, skip deriving duration โ€” will be filled by parent
499
+ const start = isBgNoOwnTiming
500
+ ? (typeof node.start === "number" ? node.start : undefined)
501
+ : (parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0);
502
+ const duration = isBgNoOwnTiming ? undefined : deriveLeafDuration(node, ctx);
503
+ const end = duration != null ? start! + duration : undefined;
496
504
 
497
505
  const base = {
498
506
  id,
499
507
  style: node.style,
500
508
  visible: node.visible ?? true,
501
509
  isBackground: node.isBackground,
502
- start,
510
+ start: isBgNoOwnTiming ? (typeof node.start === "number" ? node.start : undefined) : start,
503
511
  end,
504
- startFrom: node.type === "video" || node.type === "audio" ? node.startFrom : undefined,
505
- endAt: node.type === "video" || node.type === "audio" ? node.endAt : undefined,
512
+ startFrom: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.startFrom : undefined),
513
+ endAt: isBgNoOwnTiming ? undefined : (node.type === "video" || node.type === "audio" ? node.endAt : undefined),
506
514
  durationInSeconds: end,
507
515
  ...pickOn(node),
508
516
  };
@@ -518,7 +526,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
518
526
  width: node.width ?? 1080,
519
527
  height: node.height ?? 1920,
520
528
  };
521
- return { stream, duration: end };
529
+ return { stream, duration: end ?? 0 };
522
530
  }
523
531
  case "audio": {
524
532
  const stream: Audio = {
@@ -529,7 +537,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
529
537
  foreground: node.foreground,
530
538
  speaker: node.speaker,
531
539
  };
532
- return { stream, duration: end };
540
+ return { stream, duration: end ?? 0 };
533
541
  }
534
542
  case "image": {
535
543
  const stream: Image = {
@@ -538,7 +546,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
538
546
  src: node.src,
539
547
  fit: node.fit ?? "contain",
540
548
  };
541
- return { stream, duration: end };
549
+ return { stream, duration: end ?? 0 };
542
550
  }
543
551
  case "component": {
544
552
  // Collect extra properties from descriptive node (e.g. `source` from ~~~md source fences)
@@ -560,7 +568,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
560
568
  jsx: node.jsx,
561
569
  data: Object.keys(bindings).length ? bindings : undefined,
562
570
  };
563
- return { stream, duration: end };
571
+ return { stream, duration: end ?? 0 };
564
572
  }
565
573
  case "rhythm": {
566
574
  // rhythm without children compiles as a plain audio leaf
@@ -572,7 +580,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
572
580
  spots: node.spots,
573
581
  children: [],
574
582
  };
575
- return { stream, duration: end };
583
+ return { stream, duration: end ?? 0 };
576
584
  }
577
585
  case "map": {
578
586
  const stream: MapStream = {
@@ -588,7 +596,7 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
588
596
  routeMarker: node.routeMarker ?? "๐Ÿš—",
589
597
  googleMapsApiKey: ctx.googleMapsApiKey,
590
598
  };
591
- return { stream, duration: end };
599
+ return { stream, duration: end ?? 0 };
592
600
  }
593
601
  }
594
602
  }
@@ -676,6 +684,16 @@ function compileScene(
676
684
  ? aggregateDuration(compiledChildren, "parallel")
677
685
  : aggregateDuration(compiledChildren, sceneKind, resolved.time);
678
686
  const localDuration = Math.max(node.duration ?? 0, sceneContentDuration);
687
+
688
+ // Back-propagate parent duration to background children without own timing
689
+ for (const c of compiledChildren) {
690
+ if (c.stream.isBackground && c.stream.end == null) {
691
+ c.stream.end = localDuration;
692
+ c.stream.durationInSeconds = localDuration;
693
+ if (c.stream.start == null) c.stream.start = 0;
694
+ }
695
+ }
696
+
679
697
  const start = parentKind === "parallel" ? Math.max(0, node.start ?? 0) : 0;
680
698
  const end = start + localDuration;
681
699
 
@@ -814,6 +832,15 @@ function compileContainer(node: DescriptiveContainer, ctx: CompileContext, paren
814
832
  const children = compileChildren(node.children, ctx, node.type);
815
833
  const duration = aggregateDuration(children, node.type, resolved.time);
816
834
 
835
+ // Back-propagate parent duration to background children without own timing
836
+ for (const c of children) {
837
+ if (c.stream.isBackground && c.stream.end == null) {
838
+ c.stream.end = duration;
839
+ c.stream.durationInSeconds = duration;
840
+ if (c.stream.start == null) c.stream.start = 0;
841
+ }
842
+ }
843
+
817
844
  const stream: Folder = {
818
845
  id,
819
846
  type: "folder",
@@ -1200,6 +1227,10 @@ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileO
1200
1227
 
1201
1228
  // Resolve frontmatter imports / inline component defs onto each component node.
1202
1229
  const registry = resolveComponentSources(input);
1230
+ // Add built-in components so warnUnregisteredComponents doesn't flag them.
1231
+ registry.add("StoryboardSlot");
1232
+ registry.add("StoryboardCaption");
1233
+ registry.add("StoryboardInfo");
1203
1234
  warnUnregisteredComponents(input, registry);
1204
1235
 
1205
1236
  ensureUniqueIds(input.children, "root");
@@ -1207,6 +1238,15 @@ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileO
1207
1238
  const children = compileChildren(input.children, ctx, rootKind);
1208
1239
  const duration = aggregateDuration(children, rootKind, resolved.time);
1209
1240
 
1241
+ // Back-propagate root duration to background children without own timing
1242
+ for (const c of children) {
1243
+ if (c.stream.isBackground && c.stream.end == null) {
1244
+ c.stream.end = duration;
1245
+ c.stream.durationInSeconds = duration;
1246
+ if (c.stream.start == null) c.stream.start = 0;
1247
+ }
1248
+ }
1249
+
1210
1250
  const compiled: Root = {
1211
1251
  id: "root",
1212
1252
  type: "root",
@@ -401,7 +401,11 @@ function internalParse(markdown: string): VariantParseResult {
401
401
  for (const node of (mdast as any).children) {
402
402
  switch (node.type) {
403
403
  case "yaml": {
404
- // Frontmatter is metadata only โ€” does not affect video config.
404
+ // Frontmatter is metadata only โ€” store raw text for storyboard preview
405
+ const rawText = (node as any).value?.trim();
406
+ if (rawText) {
407
+ (targetRoot() as any).rawFrontmatter = rawText;
408
+ }
405
409
  break;
406
410
  }
407
411
  case "heading": {
@@ -198,6 +198,8 @@ export async function resolveMediaDurations(
198
198
  walkDown(clone as any, (node) => {
199
199
  const n = node as DescriptiveNode;
200
200
  if (n.type !== "video" && n.type !== "audio") return;
201
+ // Skip background nodes without explicit timing โ€” parent container fills duration
202
+ if (n.isBackground && typeof n.duration !== "number" && typeof n.endAt !== "number") return;
201
203
  if (typeof n.duration === "number" && n.duration > 0) return;
202
204
  if (typeof n.endAt === "number") return; // trim already specified
203
205
  if (!n.src) return;
@@ -739,6 +741,148 @@ export async function resolveGeneratedMedia(
739
741
  return clone;
740
742
  }
741
743
 
744
+ // โ”€โ”€ Storyboard Mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
745
+
746
+ /**
747
+ * Walk the descriptive tree and replace time-consuming nodes with built-in
748
+ * component placeholders so the user can preview the story structure fast:
749
+ *
750
+ * - `image`/`video` with `prompt` but no `src` โ†’ `<StoryboardSlot>` (TTI/TTV placeholder)
751
+ * - `audio` with `script` but no `src` โ†’ `<StoryboardCaption>` (caption-style text)
752
+ *
753
+ * Also prepends a `<StoryboardInfo>` card showing root metadata (title,
754
+ * dimensions, fps, variants) at the very first frame.
755
+ *
756
+ * Called early in `resolveAll` (after template-variable resolution). Since the
757
+ * nodes are now component type, subsequent steps (resolveGeneratedMedia,
758
+ * resolveMediaDurations, etc.) simply skip them โ€” no special-case logic needed.
759
+ *
760
+ * Only runs when `options.storyboard === true`.
761
+ */
762
+ export function applyStoryboardOverrides(
763
+ root: DescriptiveRoot,
764
+ options?: { variants?: string[] },
765
+ ): DescriptiveRoot {
766
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
767
+ let count = 0;
768
+
769
+ const walk = (nodes: any[]) => {
770
+ for (let i = 0; i < nodes.length; i++) {
771
+ const n = nodes[i];
772
+ if (
773
+ (n.type === "image" || n.type === "video") &&
774
+ typeof n.prompt === "string" &&
775
+ (!n.src || n.src === "auto")
776
+ ) {
777
+ // โ”€โ”€ Image/Video prompt โ†’ StoryboardSlot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
778
+ count++;
779
+ const kind = n.type === "video" ? "video" : "image";
780
+ nodes[i] = {
781
+ id: n.id,
782
+ type: "component",
783
+ jsx: `<StoryboardSlot kind="${kind}" prompt={prompt} />`,
784
+ prompt: n.prompt,
785
+ duration: n.duration,
786
+ start: n.start,
787
+ end: n.end,
788
+ style: n.style,
789
+ visible: n.visible,
790
+ isBackground: n.isBackground,
791
+ on: n.on,
792
+ instruction: n.instruction,
793
+ };
794
+ } else if (
795
+ n.type === "audio" &&
796
+ typeof n.script === "string" &&
797
+ (!n.src)
798
+ ) {
799
+ // โ”€โ”€ Audio script โ†’ StoryboardCaption โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
800
+ count++;
801
+ // Default to 5s so the caption is readable; use node duration if set.
802
+ const dur = (typeof n.duration === "number" && n.duration > 0) ? n.duration : 5;
803
+ nodes[i] = {
804
+ id: n.id,
805
+ type: "component",
806
+ jsx: `<StoryboardCaption script={script}${n.speaker ? " speaker={speaker}" : ""} />`,
807
+ script: n.script,
808
+ speaker: n.speaker,
809
+ duration: dur,
810
+ start: n.start,
811
+ end: n.end,
812
+ style: n.style,
813
+ visible: n.visible,
814
+ isBackground: n.isBackground,
815
+ on: n.on,
816
+ instruction: n.instruction,
817
+ };
818
+ } else if (Array.isArray(n.children)) {
819
+ walk(n.children);
820
+ }
821
+ }
822
+ };
823
+
824
+ walk(clone.children);
825
+
826
+ // Prepend a StoryboardInfo card with root metadata at the first frame.
827
+ // This lets the user see project context immediately.
828
+ const rootNode = clone as any;
829
+ // All binding values must be strings (the compiler's component case only
830
+ // collects string fields). Non-string values are stringified.
831
+
832
+ // Build a formatted config summary of all root-level frontmatter fields.
833
+ const configLines: string[] = [];
834
+ if (rootNode.name ?? rootNode.title) configLines.push(`title: ${rootNode.name ?? rootNode.title}`);
835
+ if (clone.width) configLines.push(`width: ${clone.width}`);
836
+ if (clone.height) configLines.push(`height: ${clone.height}`);
837
+ if (clone.fps) configLines.push(`fps: ${clone.fps}`);
838
+ if (clone.layout) configLines.push(`layout: ${clone.layout}`);
839
+ if (clone.transition) {
840
+ const t = clone.transitionTime ? `${clone.transition}(${clone.transitionTime})` : clone.transition;
841
+ configLines.push(`transition: ${t}`);
842
+ }
843
+ if (clone.tts) configLines.push(`tts: ${clone.tts}`);
844
+ if (clone.stt) configLines.push(`stt: ${clone.stt}`);
845
+ if (clone.tti) configLines.push(`tti: ${clone.tti}`);
846
+ if (clone.ttv) configLines.push(`ttv: ${clone.ttv}`);
847
+ if (clone.seed != null) configLines.push(`seed: ${clone.seed}`);
848
+ if (clone.stylesheet) configLines.push(`stylesheet: yes`);
849
+ if (clone.importsBlock) configLines.push(`imports: yes`);
850
+ if (clone.voices) configLines.push(`voices: ${Object.keys(clone.voices).join(", ")}`);
851
+ if (clone.metadata) configLines.push(`metadata: ${clone.metadata}`);
852
+
853
+ const configStr = configLines.join(" ยท ");
854
+ const variantStr = (options?.variants ?? []).join(", ");
855
+ // Frontmatter is the raw YAML block from the markdown source (if any).
856
+ const frontmatter = (rootNode as any).rawFrontmatter ?? "";
857
+ const infoComponent: any = {
858
+ type: "component",
859
+ jsx: `<StoryboardInfo config={config} variants={variants} frontmatter={frontmatter} />`,
860
+ config: configStr,
861
+ variants: variantStr,
862
+ frontmatter,
863
+ duration: 3,
864
+ start: 0,
865
+ end: 3,
866
+ visible: true,
867
+ };
868
+ const infoScene: any = {
869
+ type: "scene",
870
+ id: "storyboard-info",
871
+ name: "Overview",
872
+ title: "Storyboard Info",
873
+ instruction: "Project metadata and variant overview",
874
+ duration: 3,
875
+ children: [infoComponent],
876
+ };
877
+ clone.children = [infoScene, ...clone.children];
878
+ count++;
879
+
880
+ if (count > 0) {
881
+ console.log(` ๐ŸŽฌ Storyboard: ${count} placeholder${count > 1 ? "s" : ""} (TTI/TTV/TTS preview)`);
882
+ }
883
+ return clone;
884
+ }
885
+
742
886
  // โ”€โ”€ Combined Pipeline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
743
887
 
744
888
  export interface ResolveAllOptions extends ResolveMediaOptions {
@@ -770,6 +914,9 @@ export interface ResolveAllOptions extends ResolveMediaOptions {
770
914
  * a deterministic seed is auto-generated and written back to the source file,
771
915
  * making it stable across restarts for cache consistency. */
772
916
  sourcePath?: string;
917
+ /** Storyboard mode: skip TTI/TTV generation. The compiler will convert
918
+ * prompt image/video nodes to component placeholders instead. */
919
+ storyboard?: boolean;
773
920
  }
774
921
 
775
922
  /**
@@ -944,10 +1091,10 @@ export async function resolveAll(
944
1091
  try {
945
1092
  const raw = readFileSync(options.sourcePath, "utf-8");
946
1093
  if (options.sourcePath.endsWith(".md")) {
947
- // Insert seed: <N> after the first line (# video) or after existing header
1094
+ // Insert seed: <N> right after the `# video` line, before any config.
948
1095
  const lines = raw.split("\n");
949
- const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
950
- const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
1096
+ const videoIdx = lines.findIndex((l) => l.trim().startsWith("# video"));
1097
+ const insertAt = videoIdx >= 0 ? videoIdx + 1 : 1;
951
1098
  lines.splice(insertAt, 0, `seed:${autoSeed}`);
952
1099
  writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
953
1100
  } else if (options.sourcePath.endsWith(".json")) {
@@ -978,11 +1125,19 @@ export async function resolveAll(
978
1125
  variant: options.variants?.[0] ?? "video",
979
1126
  });
980
1127
 
981
- // Step 2: Best-match media src resolution โ€” for pattern-based src values
1128
+ // Step 2: Storyboard mode โ€” replace time-consuming nodes with built-in
1129
+ // component placeholders for fast structural preview. Converts image/video
1130
+ // prompts and audio scripts into StoryboardSlot / StoryboardCaption cards.
1131
+ // Also prepends a StoryboardInfo overlay with root metadata.
1132
+ if (options.storyboard) {
1133
+ result = applyStoryboardOverrides(result, { variants: options.variants });
1134
+ }
1135
+
1136
+ // Step 3: Best-match media src resolution โ€” for pattern-based src values
982
1137
  // (e.g. photo_${width}x${height}.jpg), find the closest existing file.
983
1138
  result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
984
1139
 
985
- // Step 3: Generate images/videos from prompts before probing durations.
1140
+ // Step 4: Generate images/videos from prompts before probing durations.
986
1141
  if (options.mediaOutputDir) {
987
1142
  result = await resolveGeneratedMedia(result, {
988
1143
  outputDir: options.mediaOutputDir,
package/src/entry.tsx CHANGED
@@ -5,6 +5,9 @@ import { ComposeContext, EventProvider, type ComposeContextValue } from "./conte
5
5
 
6
6
  import { FolderLeaf } from "./types/Folder";
7
7
  import { SubtitleOverlay } from "./types/Subtitle";
8
+ import { StoryboardSlot } from "./components/StoryboardSlot";
9
+ import { StoryboardCaption } from "./components/StoryboardCaption";
10
+ import { StoryboardInfo } from "./components/StoryboardInfo";
8
11
  import { getDurationInSeconds } from "./utils/index";
9
12
  import { root as rootSchema, type Root } from "./schema/index";
10
13
  import {
@@ -33,6 +36,20 @@ const DefaultContainer: ComposeContextValue["Container"] = ({ children, style, c
33
36
  </div>
34
37
  );
35
38
 
39
+ /**
40
+ * Built-in components available to every video without registration.
41
+ * Currently includes the storyboard placeholder; add other universal
42
+ * components here as needed.
43
+ *
44
+ * User-provided components (via compose.components or root.imports) take
45
+ * precedence โ€” they can override any builtin by using the same name.
46
+ */
47
+ const BUILTIN_COMPONENTS: Record<string, React.ComponentType<any>> = {
48
+ StoryboardSlot,
49
+ StoryboardCaption,
50
+ StoryboardInfo,
51
+ };
52
+
36
53
  /**
37
54
  * Load the component registry from root.imports.
38
55
  * - If string: import it as an ESM module (pre-bundled by server)
@@ -115,11 +132,17 @@ export function MarkCut({ root, compose, background = "#000" }: MarkCutProps) {
115
132
  React.useMemo(() => getDurationInSeconds(parsed as any, true), [parsed]);
116
133
 
117
134
  const value = React.useMemo<ComposeContextValue>(
118
- () => ({
119
- Container: compose?.Container ?? DefaultContainer,
120
- onError: compose?.onError,
121
- components: registry ?? compose?.components,
122
- }),
135
+ () => {
136
+ const userComponents = registry ?? compose?.components;
137
+ const components = userComponents
138
+ ? { ...BUILTIN_COMPONENTS, ...userComponents }
139
+ : BUILTIN_COMPONENTS;
140
+ return {
141
+ Container: compose?.Container ?? DefaultContainer,
142
+ onError: compose?.onError,
143
+ components,
144
+ };
145
+ },
123
146
  [compose, registry],
124
147
  );
125
148