@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.
@@ -224,18 +224,20 @@ function wrapWithEffects(node2, result, parentKind) {
224
224
  }
225
225
  function compileLeaf(node2, ctx, parentKind) {
226
226
  const id = node2.id ?? uid();
227
- const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
228
- const duration = deriveLeafDuration(node2, ctx);
229
- const end = start + duration;
227
+ const hasOwnDuration = typeof node2.duration === "number" || typeof node2.endAt === "number";
228
+ const isBgNoOwnTiming = node2.isBackground && !hasOwnDuration;
229
+ const start = isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
230
+ const duration = isBgNoOwnTiming ? void 0 : deriveLeafDuration(node2, ctx);
231
+ const end = duration != null ? start + duration : void 0;
230
232
  const base = {
231
233
  id,
232
234
  style: node2.style,
233
235
  visible: node2.visible ?? true,
234
236
  isBackground: node2.isBackground,
235
- start,
237
+ start: isBgNoOwnTiming ? typeof node2.start === "number" ? node2.start : void 0 : start,
236
238
  end,
237
- startFrom: node2.type === "video" || node2.type === "audio" ? node2.startFrom : void 0,
238
- endAt: node2.type === "video" || node2.type === "audio" ? node2.endAt : void 0,
239
+ startFrom: isBgNoOwnTiming ? void 0 : node2.type === "video" || node2.type === "audio" ? node2.startFrom : void 0,
240
+ endAt: isBgNoOwnTiming ? void 0 : node2.type === "video" || node2.type === "audio" ? node2.endAt : void 0,
239
241
  durationInSeconds: end,
240
242
  ...pickOn(node2)
241
243
  };
@@ -250,7 +252,7 @@ function compileLeaf(node2, ctx, parentKind) {
250
252
  width: node2.width ?? 1080,
251
253
  height: node2.height ?? 1920
252
254
  };
253
- return { stream, duration: end };
255
+ return { stream, duration: end ?? 0 };
254
256
  }
255
257
  case "audio": {
256
258
  const stream = {
@@ -261,7 +263,7 @@ function compileLeaf(node2, ctx, parentKind) {
261
263
  foreground: node2.foreground,
262
264
  speaker: node2.speaker
263
265
  };
264
- return { stream, duration: end };
266
+ return { stream, duration: end ?? 0 };
265
267
  }
266
268
  case "image": {
267
269
  const stream = {
@@ -270,7 +272,7 @@ function compileLeaf(node2, ctx, parentKind) {
270
272
  src: node2.src,
271
273
  fit: node2.fit ?? "contain"
272
274
  };
273
- return { stream, duration: end };
275
+ return { stream, duration: end ?? 0 };
274
276
  }
275
277
  case "component": {
276
278
  const KNOWN_COMPONENT_KEYS = /* @__PURE__ */ new Set([
@@ -298,7 +300,7 @@ function compileLeaf(node2, ctx, parentKind) {
298
300
  jsx: node2.jsx,
299
301
  data: Object.keys(bindings).length ? bindings : void 0
300
302
  };
301
- return { stream, duration: end };
303
+ return { stream, duration: end ?? 0 };
302
304
  }
303
305
  case "rhythm": {
304
306
  const stream = {
@@ -309,7 +311,7 @@ function compileLeaf(node2, ctx, parentKind) {
309
311
  spots: node2.spots,
310
312
  children: []
311
313
  };
312
- return { stream, duration: end };
314
+ return { stream, duration: end ?? 0 };
313
315
  }
314
316
  case "map": {
315
317
  const stream = {
@@ -325,7 +327,7 @@ function compileLeaf(node2, ctx, parentKind) {
325
327
  routeMarker: node2.routeMarker ?? "\u{1F697}",
326
328
  googleMapsApiKey: ctx.googleMapsApiKey
327
329
  };
328
- return { stream, duration: end };
330
+ return { stream, duration: end ?? 0 };
329
331
  }
330
332
  }
331
333
  }
@@ -388,6 +390,13 @@ function compileScene(node2, ctx, parentKind) {
388
390
  ];
389
391
  const sceneContentDuration = sceneKind === "parallel" ? aggregateDuration(compiledChildren, "parallel") : aggregateDuration(compiledChildren, sceneKind, resolved.time);
390
392
  const localDuration = Math.max(node2.duration ?? 0, sceneContentDuration);
393
+ for (const c of compiledChildren) {
394
+ if (c.stream.isBackground && c.stream.end == null) {
395
+ c.stream.end = localDuration;
396
+ c.stream.durationInSeconds = localDuration;
397
+ if (c.stream.start == null) c.stream.start = 0;
398
+ }
399
+ }
391
400
  const start = parentKind === "parallel" ? Math.max(0, node2.start ?? 0) : 0;
392
401
  const end = start + localDuration;
393
402
  const stream = {
@@ -481,6 +490,13 @@ function compileContainer(node2, ctx, parentKind) {
481
490
  const resolved = node2.type === "transitionSeries" ? resolveTransition(node2.transition, node2.transitionTime) : { name: "fade", time: 0.5 };
482
491
  const children = compileChildren(node2.children, ctx, node2.type);
483
492
  const duration = aggregateDuration(children, node2.type, resolved.time);
493
+ for (const c of children) {
494
+ if (c.stream.isBackground && c.stream.end == null) {
495
+ c.stream.end = duration;
496
+ c.stream.durationInSeconds = duration;
497
+ if (c.stream.start == null) c.stream.start = 0;
498
+ }
499
+ }
484
500
  const stream = {
485
501
  id,
486
502
  type: "folder",
@@ -722,10 +738,20 @@ function compileDescriptiveRoot(input, options = {}) {
722
738
  googleMapsApiKey
723
739
  };
724
740
  const registry = resolveComponentSources(input);
741
+ registry.add("StoryboardSlot");
742
+ registry.add("StoryboardCaption");
743
+ registry.add("StoryboardInfo");
725
744
  warnUnregisteredComponents(input, registry);
726
745
  ensureUniqueIds(input.children, "root");
727
746
  const children = compileChildren(input.children, ctx, rootKind);
728
747
  const duration = aggregateDuration(children, rootKind, resolved.time);
748
+ for (const c of children) {
749
+ if (c.stream.isBackground && c.stream.end == null) {
750
+ c.stream.end = duration;
751
+ c.stream.durationInSeconds = duration;
752
+ if (c.stream.start == null) c.stream.start = 0;
753
+ }
754
+ }
729
755
  const compiled = {
730
756
  id: "root",
731
757
  type: "root",
@@ -985,7 +1011,7 @@ var args = (function parseArgs(argv) {
985
1011
  "--tti": "tti",
986
1012
  "--ttv": "ttv"
987
1013
  };
988
- const args2 = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
1014
+ const args2 = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, storyboard: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
989
1015
  let i = 2;
990
1016
  if (argv[i]) args2.command = argv[i++];
991
1017
  if (argv[i] && !argv[i].startsWith("--")) args2.file = argv[i++];
@@ -1002,6 +1028,7 @@ var args = (function parseArgs(argv) {
1002
1028
  else if (flag === "--dev") args2.dev = true;
1003
1029
  else if (flag === "--label") args2.label = true;
1004
1030
  else if (flag === "--edit") args2.edit = true;
1031
+ else if (flag === "--storyboard") args2.storyboard = true;
1005
1032
  else if (flag === "--no-browser") args2.noBrowser = true;
1006
1033
  else if (flag === "--port" && argv[i]) args2.port = parseInt(argv[i], 10);
1007
1034
  else if (flag.startsWith("--port=")) args2.port = parseInt(flag.split("=")[1], 10);
@@ -10410,6 +10437,10 @@ function internalParse(markdown) {
10410
10437
  for (const node2 of mdast.children) {
10411
10438
  switch (node2.type) {
10412
10439
  case "yaml": {
10440
+ const rawText = node2.value?.trim();
10441
+ if (rawText) {
10442
+ targetRoot().rawFrontmatter = rawText;
10443
+ }
10413
10444
  break;
10414
10445
  }
10415
10446
  case "heading": {
@@ -10738,6 +10769,7 @@ async function resolveMediaDurations(root, options = {}) {
10738
10769
  walkDown(clone, (node2) => {
10739
10770
  const n = node2;
10740
10771
  if (n.type !== "video" && n.type !== "audio") return;
10772
+ if (n.isBackground && typeof n.duration !== "number" && typeof n.endAt !== "number") return;
10741
10773
  if (typeof n.duration === "number" && n.duration > 0) return;
10742
10774
  if (typeof n.endAt === "number") return;
10743
10775
  if (!n.src) return;
@@ -11046,6 +11078,103 @@ async function resolveGeneratedMedia(root, options) {
11046
11078
  }
11047
11079
  return clone;
11048
11080
  }
11081
+ function applyStoryboardOverrides(root, options) {
11082
+ const clone = JSON.parse(JSON.stringify(root));
11083
+ let count = 0;
11084
+ const walk = (nodes) => {
11085
+ for (let i = 0; i < nodes.length; i++) {
11086
+ const n = nodes[i];
11087
+ if ((n.type === "image" || n.type === "video") && typeof n.prompt === "string" && (!n.src || n.src === "auto")) {
11088
+ count++;
11089
+ const kind = n.type === "video" ? "video" : "image";
11090
+ nodes[i] = {
11091
+ id: n.id,
11092
+ type: "component",
11093
+ jsx: `<StoryboardSlot kind="${kind}" prompt={prompt} />`,
11094
+ prompt: n.prompt,
11095
+ duration: n.duration,
11096
+ start: n.start,
11097
+ end: n.end,
11098
+ style: n.style,
11099
+ visible: n.visible,
11100
+ isBackground: n.isBackground,
11101
+ on: n.on,
11102
+ instruction: n.instruction
11103
+ };
11104
+ } else if (n.type === "audio" && typeof n.script === "string" && !n.src) {
11105
+ count++;
11106
+ const dur = typeof n.duration === "number" && n.duration > 0 ? n.duration : 5;
11107
+ nodes[i] = {
11108
+ id: n.id,
11109
+ type: "component",
11110
+ jsx: `<StoryboardCaption script={script}${n.speaker ? " speaker={speaker}" : ""} />`,
11111
+ script: n.script,
11112
+ speaker: n.speaker,
11113
+ duration: dur,
11114
+ start: n.start,
11115
+ end: n.end,
11116
+ style: n.style,
11117
+ visible: n.visible,
11118
+ isBackground: n.isBackground,
11119
+ on: n.on,
11120
+ instruction: n.instruction
11121
+ };
11122
+ } else if (Array.isArray(n.children)) {
11123
+ walk(n.children);
11124
+ }
11125
+ }
11126
+ };
11127
+ walk(clone.children);
11128
+ const rootNode = clone;
11129
+ const configLines = [];
11130
+ if (rootNode.name ?? rootNode.title) configLines.push(`title: ${rootNode.name ?? rootNode.title}`);
11131
+ if (clone.width) configLines.push(`width: ${clone.width}`);
11132
+ if (clone.height) configLines.push(`height: ${clone.height}`);
11133
+ if (clone.fps) configLines.push(`fps: ${clone.fps}`);
11134
+ if (clone.layout) configLines.push(`layout: ${clone.layout}`);
11135
+ if (clone.transition) {
11136
+ const t = clone.transitionTime ? `${clone.transition}(${clone.transitionTime})` : clone.transition;
11137
+ configLines.push(`transition: ${t}`);
11138
+ }
11139
+ if (clone.tts) configLines.push(`tts: ${clone.tts}`);
11140
+ if (clone.stt) configLines.push(`stt: ${clone.stt}`);
11141
+ if (clone.tti) configLines.push(`tti: ${clone.tti}`);
11142
+ if (clone.ttv) configLines.push(`ttv: ${clone.ttv}`);
11143
+ if (clone.seed != null) configLines.push(`seed: ${clone.seed}`);
11144
+ if (clone.stylesheet) configLines.push(`stylesheet: yes`);
11145
+ if (clone.importsBlock) configLines.push(`imports: yes`);
11146
+ if (clone.voices) configLines.push(`voices: ${Object.keys(clone.voices).join(", ")}`);
11147
+ if (clone.metadata) configLines.push(`metadata: ${clone.metadata}`);
11148
+ const configStr = configLines.join(" \xB7 ");
11149
+ const variantStr = (options?.variants ?? []).join(", ");
11150
+ const frontmatter2 = rootNode.rawFrontmatter ?? "";
11151
+ const infoComponent = {
11152
+ type: "component",
11153
+ jsx: `<StoryboardInfo config={config} variants={variants} frontmatter={frontmatter} />`,
11154
+ config: configStr,
11155
+ variants: variantStr,
11156
+ frontmatter: frontmatter2,
11157
+ duration: 3,
11158
+ start: 0,
11159
+ end: 3,
11160
+ visible: true
11161
+ };
11162
+ const infoScene = {
11163
+ type: "scene",
11164
+ id: "storyboard-info",
11165
+ name: "Overview",
11166
+ title: "Storyboard Info",
11167
+ instruction: "Project metadata and variant overview",
11168
+ duration: 3,
11169
+ children: [infoComponent]
11170
+ };
11171
+ clone.children = [infoScene, ...clone.children];
11172
+ count++;
11173
+ if (count > 0) {
11174
+ console.log(` \u{1F3AC} Storyboard: ${count} placeholder${count > 1 ? "s" : ""} (TTI/TTV/TTS preview)`);
11175
+ }
11176
+ return clone;
11177
+ }
11049
11178
  async function resolveIncludes(root, options = {}) {
11050
11179
  const clone = JSON.parse(JSON.stringify(root));
11051
11180
  const baseDir = options.baseDir ?? process.cwd();
@@ -11136,8 +11265,8 @@ async function resolveAll2(root, options = {}) {
11136
11265
  const raw = readFileSync(options.sourcePath, "utf-8");
11137
11266
  if (options.sourcePath.endsWith(".md")) {
11138
11267
  const lines = raw.split("\n");
11139
- const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
11140
- const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
11268
+ const videoIdx = lines.findIndex((l) => l.trim().startsWith("# video"));
11269
+ const insertAt = videoIdx >= 0 ? videoIdx + 1 : 1;
11141
11270
  lines.splice(insertAt, 0, `seed:${autoSeed}`);
11142
11271
  writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
11143
11272
  } else if (options.sourcePath.endsWith(".json")) {
@@ -11161,6 +11290,9 @@ async function resolveAll2(root, options = {}) {
11161
11290
  fps: result.fps ?? 30,
11162
11291
  variant: options.variants?.[0] ?? "video"
11163
11292
  });
11293
+ if (options.storyboard) {
11294
+ result = applyStoryboardOverrides(result, { variants: options.variants });
11295
+ }
11164
11296
  result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
11165
11297
  if (options.mediaOutputDir) {
11166
11298
  result = await resolveGeneratedMedia(result, {
@@ -11220,7 +11352,8 @@ async function resolveAndCompile(data, options = {}) {
11220
11352
  sttCli: options.sttCli,
11221
11353
  subtitleOutputDir: options.subtitleOutputDir,
11222
11354
  seed: options.seed,
11223
- variants: options.variants
11355
+ variants: options.variants,
11356
+ storyboard: options.storyboard
11224
11357
  });
11225
11358
  const compiled = compileDescriptiveRoot(resolved, {
11226
11359
  googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY
@@ -45,6 +45,9 @@ export interface ResolveAndCompileOptions {
45
45
  /** Path to the source file (.md or .json). When set and root.seed is missing,
46
46
  * a deterministic seed is auto-generated and written back to the source file. */
47
47
  sourcePath?: string;
48
+ /** Storyboard mode: skip TTI/TTV generation, convert prompt image/video nodes
49
+ * to component placeholders for fast structural preview. */
50
+ storyboard?: boolean;
48
51
  }
49
52
 
50
53
  /**
@@ -99,6 +102,7 @@ export async function resolveAndCompile(
99
102
  subtitleOutputDir: options.subtitleOutputDir,
100
103
  seed: options.seed,
101
104
  variants: options.variants,
105
+ storyboard: options.storyboard,
102
106
  });
103
107
 
104
108
  // 2. Sync compile: descriptive → stream tree
@@ -40,28 +40,44 @@ export function extractScenes(root) {
40
40
  for (const s of root.children) {
41
41
  if (!(s.type === "folder" || s.type === "scene" || s.children?.length)) continue;
42
42
  if (s.isBackground) continue;
43
- // Use durationInSeconds (set by compiler) as the definitive duration.
44
- // Fall back to leaf end-start for backward compatibility.
45
- const leaf = (s.children || []).find(c => c.src && (c.type === "image" || c.type === "video"));
43
+ // Compute scene duration. Priority:
44
+ // 1. s.durationInSeconds (set by engine's getDurationInSeconds)
45
+ // 2. Sum of children's durationInSeconds (for series) or max (for parallel)
46
+ // 3. Leaf node's end-start (backward compat with old compiled JSON)
47
+ // 4. Default 5s
46
48
  let dur = s.durationInSeconds;
47
49
  if (!dur || dur <= 0) {
50
+ const kids = s.children || [];
51
+ const childDurs = kids
52
+ .filter(c => !c.isBackground)
53
+ .map(c => c.durationInSeconds ?? (c.end != null ? c.end - (c.start ?? 0) : 0))
54
+ .filter(d => d > 0);
55
+ if (childDurs.length > 0) {
56
+ // Series → sum; Parallel → max. We don't know which here, so sum is
57
+ // the safe upper bound (parallel scenes are rare as direct root children).
58
+ dur = childDurs.reduce((a, b) => a + b, 0);
59
+ }
60
+ }
61
+ if (!dur || dur <= 0) {
62
+ const leaf = (s.children || []).find(c => c.src && (c.type === "image" || c.type === "video"));
48
63
  const src2 = leaf || s;
49
64
  dur = (src2.end ?? 5) - (src2.start ?? 0);
50
65
  }
51
- // Account for transition overlap: scene i starts at offset - i*overlap
52
- const transitionOffset = scenes.length * overlap;
53
- const start = Math.max(0, offset - transitionOffset);
66
+ const leaf = (s.children || []).find(c => c.src && (c.type === "image" || c.type === "video"));
67
+ // Use the raw cumulative offset as the scene start time. Transition overlap
68
+ // is a rendering detail — scene thumbnails seek to the raw boundary so the
69
+ // user lands on the first frame of the intended scene, not inside a transition.
54
70
  scenes.push({
55
71
  name: s.name || s.id || "scene",
56
- start,
57
- end: start + dur,
72
+ start: offset,
73
+ end: offset + dur,
58
74
  duration: dur,
59
75
  src: leaf?.src || "",
60
76
  mediaType: leaf?.type || "unknown",
61
77
  });
62
78
  offset += dur;
63
79
  }
64
- totalDuration = offset - Math.max(0, (scenes.length - 1)) * overlap;
80
+ totalDuration = offset;
65
81
  }
66
82
 
67
83
  // Format 2: scenes wrapped in a "scenes" folder
@@ -30,6 +30,10 @@ const VIDEO_JSON = inputArg ? resolve(inputArg) : join(ROOT, "video.json");
30
30
  const IS_MARKDOWN = VIDEO_JSON.endsWith(".md");
31
31
  const MODE_LABEL = process.argv.includes("--label");
32
32
  const MODE_EDIT = process.argv.includes("--edit");
33
+ // Storyboard mode: skip slow TTI/TTV generation. The compiler converts prompt
34
+ // image/video nodes into component placeholders so the user can review the
35
+ // story structure fast and chat to reshape it.
36
+ const MODE_STORYBOARD = process.argv.includes("--storyboard");
33
37
 
34
38
  // ─── Variant configuration ─────────────────────────────────────────────────
35
39
  // Each --variant flag defines a separate compilation target.
@@ -326,9 +330,11 @@ function resolveAssetPaths(root) {
326
330
  if (typeof node.src === "string" && !node.src.startsWith("http://") && !node.src.startsWith("https://") && !node.src.startsWith("data:")) {
327
331
  // Already a relative path under MARKCUT_BASE (shouldn't be absolute)
328
332
  if (!node.src.startsWith("/") && !node.src.startsWith(".")) {
329
- if (node.src.startsWith("generated/") || node.src.startsWith(BASENAME + "/")) {
330
- node.src = "/" + node.src;
331
- }
333
+ // Plain relative path — prefix with / (server serves from project root)
334
+ node.src = "/" + node.src;
335
+ } else if (node.src.startsWith("./")) {
336
+ // Dot-prefixed relative path — strip ./ and prefix with /
337
+ node.src = "/" + node.src.slice(2);
332
338
  }
333
339
  // Convert absolute paths under MARKCUT_BASE
334
340
  if (typeof node.src === "string" && node.src.startsWith(MARKCUT_BASE)) {
@@ -442,6 +448,7 @@ async function compileVariant(config, parsed, raw) {
442
448
  mediaOutputDir: MEDIA_OUTPUT_DIR,
443
449
  includeOutputDir: INCLUDE_CACHE_DIR,
444
450
  subtitleOutputDir: subtitleDir,
451
+ storyboard: MODE_STORYBOARD,
445
452
  variants: config.chain.length > 0 ? config.chain : undefined,
446
453
  });
447
454
  compiled = compileDescriptiveRoot(resolved);
@@ -465,6 +472,7 @@ async function compileVariant(config, parsed, raw) {
465
472
  mediaOutputDir: MEDIA_OUTPUT_DIR,
466
473
  includeOutputDir: INCLUDE_CACHE_DIR,
467
474
  subtitleOutputDir: subtitleDir,
475
+ storyboard: MODE_STORYBOARD,
468
476
  variants: config.chain.length > 0 ? config.chain : undefined,
469
477
  });
470
478
  } else {
@@ -65,6 +65,9 @@ Commands:
65
65
 
66
66
  preview <file.json|.md> Open player with live preview
67
67
  --label Open label input overlay
68
+ --storyboard Fast structure preview: show TTI/TTV prompts as
69
+ placeholder components (skips slow generation).
70
+ Implies --edit so you can reshape the story.
68
71
  --no-browser Skip opening browser automatically
69
72
  --port <num> Port for the player server (default: 3001)
70
73
 
@@ -72,11 +75,11 @@ Commands:
72
75
  --output <path> Output path (default: out/video.mp4)
73
76
  --verbose Show full per-frame progress (default: compact)
74
77
 
75
- vision <folder> Extract metadata into metadata.json
76
- --label Full pipeline: preview → label → normalize → percept → segments
78
+ vision <folder> Full pipeline: extract → normalize → percept → segments
79
+ --label Add interactive labeling step before AI pipeline
80
+ --instruct "text" Background context about people/places (injected into prompts)
77
81
  --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
78
82
  --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
79
- --context "text" Background context about people/places (injected into prompts)
80
83
  --skip-stt Skip speech-to-text for videos
81
84
  --dry-run Show what would be processed without running AI
82
85
  --show-prompts Print the prompts file and exit
@@ -209,6 +212,10 @@ edit=${DEFAULT_EDIT_CLI}`);
209
212
  }
210
213
 
211
214
  if (args.command === "preview") {
215
+ // --storyboard implies --edit: show story structure fast (prompts as
216
+ // placeholder components) and let user chat to reshape before generation.
217
+ if (args.storyboard) args.edit = true;
218
+
212
219
  // Auto-detect: markdown files must use the player server (not Remotion Studio)
213
220
  const isMarkdown = args.file?.endsWith(".md");
214
221
  if (isMarkdown && !args.edit && !args.label) {
@@ -223,12 +230,13 @@ edit=${DEFAULT_EDIT_CLI}`);
223
230
  }
224
231
  const labelFlag = args.label ? "--label" : "";
225
232
  const editFlag = args.edit ? "--edit" : "";
233
+ const storyboardFlag = args.storyboard ? "--storyboard" : "";
226
234
  const portFlag = `--port=${args.port || 3001}`;
227
235
  const fileFlag = args.file || join(ROOT, "video.json");
228
236
  const port = args.port || 3001;
229
237
  // Pass variant flags to the server
230
238
  const variantFlags = args.variant.map(v => `--variant=${v}`);
231
- const serverArgs = [playerServer, resolve(fileFlag), labelFlag, editFlag, portFlag, ...variantFlags].filter(Boolean);
239
+ const serverArgs = [playerServer, resolve(fileFlag), labelFlag, editFlag, storyboardFlag, portFlag, ...variantFlags].filter(Boolean);
232
240
  const child = spawn("node", serverArgs, { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"] });
233
241
  let serverReady = false;
234
242
  let stdoutBuffer = "";
@@ -57,7 +57,7 @@ export const folder = base.extend({
57
57
  export type Folder = z.infer<typeof folder>;
58
58
 
59
59
  export const subtitleOverlay = z.object({
60
- src: z.string().describe("path or URL to VTT file covering the full video duration"),
60
+ src: z.string().optional().describe("path or URL to VTT file covering the full video duration; omitted when no subtitles are available"),
61
61
  style: z.string().optional().describe("inline css for the overlay container"),
62
62
  fontSize: z.union([z.number(), z.string()]).optional(),
63
63
  type: z.string().optional().describe("caption animation type: bounce, fade, typewriter, colorful, glowing, neon, etc."),
@@ -172,8 +172,8 @@ export function FolderLeaf({ stream }: { stream: FolderStream }) {
172
172
  style={containerStyle}
173
173
  className={`${orientation} ${stream.type}`.trim()}
174
174
  >
175
- {isSeries ? <TypedSeries>{sequences}</TypedSeries> : sequences}
176
175
  {bgContent}
176
+ {isSeries ? <TypedSeries>{sequences}</TypedSeries> : sequences}
177
177
  </Container>
178
178
  </AudioContext.Provider>
179
179
  );
@@ -237,6 +237,9 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
237
237
  const { fps } = useVideoConfig();
238
238
  const [cues, setCues] = React.useState<Cue[] | null>(null);
239
239
 
240
+ // No subtitle source — nothing to render
241
+ if (!subtitle.src) return null;
242
+
240
243
  const CaptionComponent = React.useMemo(
241
244
  () => resolveCaption(subtitle.type),
242
245
  [subtitle.type],
@@ -244,6 +247,7 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
244
247
 
245
248
  React.useEffect(() => {
246
249
  const { src } = subtitle;
250
+ if (!src) return;
247
251
 
248
252
  // Inline VTT body
249
253
  if (src.includes("-->")) {
@@ -648,9 +648,12 @@ function buildPreviewTree(folder, metadata) {
648
648
  }
649
649
  entries.sort((a, b) => a.created.localeCompare(b.created));
650
650
 
651
+ // Random seed for TTI/TTV generation.
652
+ const seed = Math.floor(Math.random() * 0xFFFFFFFF);
653
+
651
654
  return {
652
655
  id: "root", type: "root", width: 1080, height: 1920, fps: 30,
653
- isSeries: true, transition: "fade", transitionTime: 0.5,
656
+ seed, isSeries: true, transition: "fade", transitionTime: 0.5,
654
657
  children: entries.map((e) => ({
655
658
  id: e.name, type: "folder", isSeries: false,
656
659
  children: [{
@@ -1093,14 +1096,14 @@ function printUsage() {
1093
1096
  markcut vision — Analyze images and videos in a folder for video generation
1094
1097
 
1095
1098
  Usage:
1096
- markcut vision <folder> Extract metadata into metadata.json
1097
- markcut vision <folder> --label Full pipeline: preview → label → normalize → percept → segments
1099
+ markcut vision <folder> Full pipeline: extract → normalize → percept → segments
1100
+ markcut vision <folder> --label Same as default, plus interactive labeling step before AI
1098
1101
 
1099
1102
  Options:
1100
- --label Open label preview → then continue with full AI pipeline
1103
+ --label Open interactive labeling preview before running AI pipeline
1104
+ --instruct "text" Background context about people/places (injected into prompts)
1101
1105
  --prompts-file <path> Path to prompts markdown file (default: vision_prompts.md)
1102
1106
  --vtt-sample-interval <n> Sample one video frame every N seconds (default: 5)
1103
- --context "text" Background context about people/places (injected into prompts)
1104
1107
  --pick <files> Comma-separated filenames to process
1105
1108
  --skip-stt Skip speech-to-text for videos
1106
1109
  --dry-run Show what would be processed without running AI
@@ -1134,9 +1137,8 @@ export async function main(args) {
1134
1137
 
1135
1138
  else if (flag === "--prompts-file" && args[i]) { promptsFile = resolve(args[i++]); }
1136
1139
  else if (flag === "--vtt-sample-interval" && args[i]) { vttSampleInterval = parseInt(args[i++], 10) || DEFAULT_VTT_SAMPLE_INTERVAL; }
1137
- else if (flag === "--context" && args[i]) { context = args[i++]; }
1140
+ else if (flag === "--instruct" && args[i]) { context = args[i++]; }
1138
1141
  else if (flag === "--show-prompts") { console.log(readFileSync(promptsFile, "utf-8")); return; }
1139
-
1140
1142
  else if (flag === "--skip-stt") { skipSTT = true; }
1141
1143
  else if (flag === "--pick" && args[i]) { for (const f of args[i++].split(",")) pickSet.add(f.trim()); }
1142
1144
  else if (flag === "--dry-run") { dryRun = true; }
@@ -1151,12 +1153,45 @@ export async function main(args) {
1151
1153
  for (const [name, value] of promptOverrides) prompts.set(name, value);
1152
1154
 
1153
1155
  if (doLabel) {
1156
+ // Full pipeline with interactive labeling
1154
1157
  await runFullPipeline(folder, prompts, context, pickSet, vttSampleInterval, skipSTT, dryRun);
1155
1158
  } else {
1156
- await runMetadataMode(folder, pickSet, dryRun);
1159
+ // Default: full pipeline without labeling (extract → normalize → percept → segments)
1160
+ const metadataPath = join(folder, "metadata.json");
1161
+ if (!existsSync(metadataPath)) {
1162
+ emitInfo(`\n📋 Extracting metadata...`);
1163
+ await runMetadataMode(folder, pickSet, false);
1164
+ }
1165
+ if (!existsSync(metadataPath) || Object.keys(JSON.parse(readFileSync(metadataPath, "utf-8"))).length === 0) {
1166
+ emitError("No media files found — nothing to process.");
1167
+ return;
1168
+ }
1169
+ emitInfo(`\n🔧 Running full pipeline (normalize → perceive)...`);
1170
+ await runNormalizeAndPercept(folder, metadataPath, prompts, context, pickSet, vttSampleInterval, skipSTT);
1157
1171
  }
1158
1172
  }
1159
1173
 
1174
+ // ═════════════════════════════════════════════════════════════════════════
1175
+ // Exported for testing
1176
+ // ═════════════════════════════════════════════════════════════════════════
1177
+
1178
+ export {
1179
+ scanMedia,
1180
+ buildPreviewTree,
1181
+ mergeLabelsIntoMetadata,
1182
+ buildMergedCues,
1183
+ parseVTT,
1184
+ loadPrompts,
1185
+ substituteTemplate,
1186
+ shQuote,
1187
+ normalizePerception,
1188
+ extractRawText,
1189
+ stripThinkBlocks,
1190
+ looseJSONParse,
1191
+ formatTime,
1192
+ timeToSeconds,
1193
+ };
1194
+
1160
1195
  // Direct execution
1161
1196
  if (process.argv[1] && process.argv[1].includes("src/vision/cli.mjs")) {
1162
1197
  main(process.argv).catch((e) => { emitError(e.message); process.exit(1); });
@@ -0,0 +1,8 @@
1
+ {
2
+ "test-photo": {
3
+ "width": 64,
4
+ "height": 64,
5
+ "created": null,
6
+ "location": null
7
+ }
8
+ }