@lalalic/markcut 2.3.0 → 2.4.1

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.
@@ -30520,7 +30520,7 @@ function VideoLeaf({ stream: stream2 }) {
30520
30520
  const startFrom = stream2.startFrom ?? 0;
30521
30521
  const endAt = stream2.endAt ?? totalDur;
30522
30522
  const volume = stream2.volume ?? 1;
30523
- const playbackRate = stream2.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
30523
+ const playbackRate = Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
30524
30524
  const streamStyle = cssJS(stream2.style);
30525
30525
  const hasAnimation = "animation" in streamStyle;
30526
30526
  return /* @__PURE__ */ (0, import_jsx_runtime78.jsx)(
@@ -30580,7 +30580,7 @@ function AudioLeaf({ stream: stream2 }) {
30580
30580
  const startFrom = stream2.startFrom ?? 0;
30581
30581
  const endAt = stream2.endAt ?? totalDur;
30582
30582
  const volume = stream2.volume ?? 1;
30583
- const playbackRate = stream2.loop ? 1 : toPlaybackRate((endAt - startFrom) / (end - start));
30583
+ const playbackRate = toPlaybackRate((endAt - startFrom) / (end - start));
30584
30584
  return /* @__PURE__ */ (0, import_jsx_runtime79.jsx)(
30585
30585
  Sequence,
30586
30586
  {
@@ -30597,7 +30597,6 @@ function AudioLeaf({ stream: stream2 }) {
30597
30597
  endAt: Math.floor(startFrom * fps) + Math.floor((endAt - startFrom) * fps / playbackRate),
30598
30598
  muted: volume === 0 || !!ctx?.foreground,
30599
30599
  volume,
30600
- loop: (stream2.loop ?? 1) > 1,
30601
30600
  playbackRate,
30602
30601
  showInTimeline: false
30603
30602
  }
@@ -44711,11 +44710,6 @@ function getCurrentStep(leg, currentInSecond) {
44711
44710
  // src/types/Include.tsx
44712
44711
  var React41 = __toESM(require_react(), 1);
44713
44712
  var import_jsx_runtime84 = __toESM(require_jsx_runtime(), 1);
44714
- var ASPECT_DIMS = {
44715
- "16x9": { width: 1920, height: 1080 },
44716
- "9x16": { width: 1080, height: 1920 },
44717
- "1x1": { width: 1080, height: 1080 }
44718
- };
44719
44713
  function isSceneBased(data2) {
44720
44714
  if (!data2 || typeof data2 !== "object") return false;
44721
44715
  const d2 = data2;
@@ -44838,8 +44832,7 @@ function IncludeLeaf({ stream: stream2 }) {
44838
44832
  if (isSceneBased(externalData)) {
44839
44833
  const vj = externalData;
44840
44834
  const vjFps = vj.meta.fps ?? parentFps;
44841
- const aspectKey = vj.meta.aspects?.[0] ?? "16x9";
44842
- const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
44835
+ const dims = { width: parentWidth, height: parentHeight };
44843
44836
  return /* @__PURE__ */ (0, import_jsx_runtime84.jsxs)(AbsoluteFill, { style: { backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }, children: [
44844
44837
  vj.bgm && /* @__PURE__ */ (0, import_jsx_runtime84.jsx)(Audio, { src: vj.bgm.src, volume: vj.bgm.baseVolume }),
44845
44838
  vj.scenes.map((scene2) => {
@@ -45891,15 +45884,41 @@ function supportHtml(text) {
45891
45884
  });
45892
45885
  return el;
45893
45886
  }
45887
+ function stripHtml(text) {
45888
+ return text.replace(/<[^>]*>/g, "").trim();
45889
+ }
45890
+ function groupConsecutiveCues(cues) {
45891
+ const groups = [];
45892
+ let current2 = [];
45893
+ for (const cue of cues) {
45894
+ const plain = stripHtml(cue.text);
45895
+ if (current2.length === 0) {
45896
+ current2.push(cue);
45897
+ } else {
45898
+ const prevPlain = stripHtml(current2[current2.length - 1].text);
45899
+ if (plain === prevPlain) {
45900
+ current2.push(cue);
45901
+ } else {
45902
+ groups.push(current2);
45903
+ current2 = [cue];
45904
+ }
45905
+ }
45906
+ }
45907
+ if (current2.length > 0) groups.push(current2);
45908
+ return groups;
45909
+ }
45894
45910
  function CueFrame({
45895
45911
  cue,
45896
45912
  fps,
45897
45913
  CaptionComponent,
45898
- subtitle
45914
+ subtitle,
45915
+ cueGroup
45899
45916
  }) {
45900
- const durationInFrames = Math.max(1, Math.floor((cue.endAt - cue.startFrom) * fps));
45901
- const from = Math.floor(cue.startFrom * fps);
45902
- const captionText = React46.useMemo(() => supportHtml(cue.text), [cue.text]);
45917
+ const group = cueGroup ?? [cue];
45918
+ const startFrom = group[0].startFrom;
45919
+ const endAt = group[group.length - 1].endAt;
45920
+ const durationInFrames = Math.max(1, Math.floor((endAt - startFrom) * fps));
45921
+ const from = Math.floor(startFrom * fps);
45903
45922
  const textStyle = React46.useMemo(
45904
45923
  () => ({
45905
45924
  ...DEFAULT_TEXT_STYLE,
@@ -45909,7 +45928,33 @@ function CueFrame({
45909
45928
  }),
45910
45929
  [subtitle.fontSize, subtitle.fontFamily, subtitle.fontStyle]
45911
45930
  );
45912
- return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(Sequence, { layout: "none", durationInFrames, from, children: /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(CaptionComponent, { text: captionText, style: textStyle }) });
45931
+ return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(Sequence, { layout: "none", durationInFrames, from, children: /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
45932
+ GroupedCueInner,
45933
+ {
45934
+ group,
45935
+ fps,
45936
+ CaptionComponent,
45937
+ textStyle
45938
+ }
45939
+ ) });
45940
+ }
45941
+ function GroupedCueInner({
45942
+ group,
45943
+ fps,
45944
+ CaptionComponent,
45945
+ textStyle
45946
+ }) {
45947
+ const frame = useCurrentFrame();
45948
+ const currentTime = frame / fps;
45949
+ let activeCue = group[group.length - 1];
45950
+ for (const c3 of group) {
45951
+ if (currentTime >= c3.startFrom && currentTime < c3.endAt) {
45952
+ activeCue = c3;
45953
+ break;
45954
+ }
45955
+ }
45956
+ const captionText = React46.useMemo(() => supportHtml(activeCue.text), [activeCue.text]);
45957
+ return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(CaptionComponent, { text: captionText, style: textStyle });
45913
45958
  }
45914
45959
  function SubtitleOverlay({ subtitle }) {
45915
45960
  const { fps } = useVideoConfig();
@@ -45955,15 +46000,17 @@ function SubtitleOverlay({ subtitle }) {
45955
46000
  }, [subtitle.src]);
45956
46001
  if (!cues) return null;
45957
46002
  const boxCss = subtitle.style ? cssJS(subtitle.style) : {};
45958
- return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)("div", { className: `${subtitle.type || "default"} subtitle-overlay`, style: { ...DEFAULT_BOX_STYLE, ...boxCss }, children: cues.map((cue, i3) => /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
46003
+ const cueGroups = React46.useMemo(() => groupConsecutiveCues(cues), [cues]);
46004
+ return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)("div", { className: `${subtitle.type || "default"} subtitle-overlay`, style: { ...DEFAULT_BOX_STYLE, ...boxCss }, children: cueGroups.map((group, gi) => /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
45959
46005
  CueFrame,
45960
46006
  {
45961
- cue,
46007
+ cue: group[0],
46008
+ cueGroup: group,
45962
46009
  fps,
45963
46010
  CaptionComponent,
45964
46011
  subtitle
45965
46012
  },
45966
- `${i3}-${cue.startFrom}-${cue.endAt}`
46013
+ `g${gi}-${group[0].startFrom}-${group[group.length - 1].endAt}`
45967
46014
  )) });
45968
46015
  }
45969
46016
 
@@ -59804,15 +59851,13 @@ var video = base.extend({
59804
59851
  volume: external_exports.number().min(0).max(1).default(1),
59805
59852
  playbackRate: external_exports.number().optional(),
59806
59853
  width: external_exports.number().default(1080),
59807
- height: external_exports.number().default(1920),
59808
- loop: external_exports.number().int().min(1).optional().describe(">1 = loop count")
59854
+ height: external_exports.number().default(1920)
59809
59855
  });
59810
59856
  var audio = base.extend({
59811
59857
  type: external_exports.literal("audio").default("audio"),
59812
59858
  src: external_exports.string().optional(),
59813
59859
  volume: external_exports.number().min(0).max(1).default(1),
59814
59860
  foreground: external_exports.boolean().optional().describe("ducks parent video audio while playing"),
59815
- loop: external_exports.number().int().min(1).optional().describe(">1 = loop count"),
59816
59861
  speaker: external_exports.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue")
59817
59862
  });
59818
59863
  var image = base.extend({
@@ -60361,8 +60406,8 @@ function SceneThumbnails({ currentTime, onSeek }) {
60361
60406
  }, []);
60362
60407
  if (scenes.length === 0) return null;
60363
60408
  let activeIdx = -1;
60364
- for (let i3 = 0; i3 < scenes.length; i3++) {
60365
- if (currentTime >= scenes[i3].start && currentTime < scenes[i3].end) {
60409
+ for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
60410
+ if (currentTime >= scenes[i3].start) {
60366
60411
  activeIdx = i3;
60367
60412
  break;
60368
60413
  }
@@ -60498,9 +60543,9 @@ function PlayerApp() {
60498
60543
  const scenes = window.__scenes;
60499
60544
  if (!scenes) return;
60500
60545
  let found = "";
60501
- for (const s2 of scenes) {
60502
- if (currentTime >= s2.start && currentTime < s2.end) {
60503
- found = s2.name || "";
60546
+ for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
60547
+ if (currentTime >= scenes[i3].start) {
60548
+ found = scenes[i3].name || "";
60504
60549
  break;
60505
60550
  }
60506
60551
  }
@@ -60555,10 +60600,20 @@ function PlayerApp() {
60555
60600
  const handleFrameUpdate = React55.useCallback((frame) => {
60556
60601
  currentFrameRef.current = frame;
60557
60602
  setCurrentTime((prev) => {
60558
- const newTime = frame / (data2?.fps ?? 30);
60559
- return Math.abs(newTime - prev) > 0.5 ? newTime : prev;
60603
+ const newTime = frame / fps;
60604
+ return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
60560
60605
  });
60561
- }, [data2?.fps]);
60606
+ }, [fps]);
60607
+ React55.useEffect(() => {
60608
+ const p2 = playerRef.current;
60609
+ if (!p2 || typeof p2.addEventListener !== "function") return;
60610
+ const listener = (e) => {
60611
+ const frame = e?.detail?.frame;
60612
+ if (typeof frame === "number") handleFrameUpdate(frame);
60613
+ };
60614
+ p2.addEventListener("frameupdate", listener);
60615
+ return () => p2.removeEventListener("frameupdate", listener);
60616
+ }, [ready, data2, handleFrameUpdate]);
60562
60617
  React55.useEffect(() => {
60563
60618
  if (!ready || !playerRef.current) return;
60564
60619
  const FWD_SECONDS = 5;
@@ -60567,13 +60622,16 @@ function PlayerApp() {
60567
60622
  function seekRelative(deltaSec) {
60568
60623
  const p2 = playerRef.current;
60569
60624
  if (!p2) return;
60570
- const frame = p2.getCurrentFrame() + Math.round(deltaSec * fps);
60571
- p2.seekTo(Math.max(0, frame));
60625
+ const frame = Math.max(0, p2.getCurrentFrame() + Math.round(deltaSec * fps));
60626
+ p2.seekTo(frame);
60627
+ setCurrentTime(frame / fps);
60572
60628
  }
60573
60629
  function seekPercent(pct) {
60574
60630
  const p2 = playerRef.current;
60575
60631
  if (!p2) return;
60576
- p2.seekTo(Math.round(pct * durationInFrames));
60632
+ const frame = Math.round(pct * durationInFrames);
60633
+ p2.seekTo(frame);
60634
+ setCurrentTime(frame / fps);
60577
60635
  }
60578
60636
  function showHelp() {
60579
60637
  console.log(`%c\u{1F3AC} MarkCut Player Shortcuts
@@ -60746,8 +60804,7 @@ function PlayerApp() {
60746
60804
  allowFullscreen: true,
60747
60805
  clickToPlay: false,
60748
60806
  doubleClickToFullscreen: true,
60749
- autoPlay,
60750
- onFrameUpdate: handleFrameUpdate
60807
+ autoPlay
60751
60808
  }
60752
60809
  ) }),
60753
60810
  /* @__PURE__ */ (0, import_jsx_runtime95.jsx)(
@@ -60756,8 +60813,9 @@ function PlayerApp() {
60756
60813
  currentTime,
60757
60814
  onSeek: (t) => {
60758
60815
  if (playerRef.current) {
60759
- const frame = Math.round(t * (data2?.fps ?? 30));
60816
+ const frame = Math.round(t * fps);
60760
60817
  playerRef.current.seekTo(frame);
60818
+ setCurrentTime(t);
60761
60819
  }
60762
60820
  }
60763
60821
  }
@@ -35,9 +35,12 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
35
35
 
36
36
  if (scenes.length === 0) return null;
37
37
 
38
+ // With transition overlaps, scenes can overlap in time (scene[i].end > scene[i+1].start).
39
+ // Iterate backward so the latest scene whose start <= currentTime wins — i.e., as
40
+ // soon as the next scene starts (during a transition), it becomes the active one.
38
41
  let activeIdx = -1;
39
- for (let i = 0; i < scenes.length; i++) {
40
- if (currentTime >= scenes[i].start && currentTime < scenes[i].end) {
42
+ for (let i = scenes.length - 1; i >= 0; i--) {
43
+ if (currentTime >= scenes[i].start) {
41
44
  activeIdx = i;
42
45
  break;
43
46
  }
@@ -259,7 +259,6 @@ function compileLeaf(node2, ctx, parentKind) {
259
259
  src: node2.src,
260
260
  volume: node2.volume ?? 1,
261
261
  foreground: node2.foreground,
262
- loop: node2.loop,
263
262
  speaker: node2.speaker
264
263
  };
265
264
  return { stream, duration: end };
@@ -709,8 +708,11 @@ function resolveVariantOverrides(root, variantChain) {
709
708
  }
710
709
  function compileDescriptiveRoot(input, options = {}) {
711
710
  const root = typeof input === "string" ? JSON.parse(input) : input;
712
- const resolved = resolveTransition(root.layout ? root.transition ?? root.layout : root.transition, root.transitionTime);
713
- const rootKind = root.layout === "parallel" ? "parallel" : "series";
711
+ const resolved = resolveTransition(
712
+ root.layout === "transitionSeries" ? root.transition ?? "fade" : root.transition,
713
+ root.transitionTime
714
+ );
715
+ const rootKind = root.layout === "parallel" ? "parallel" : root.layout === "transitionSeries" ? "transitionSeries" : "series";
714
716
  const googleMapsApiKey = options.googleMapsApiKey ?? "";
715
717
  const ctx = {
716
718
  defaults: {
@@ -736,8 +738,8 @@ function compileDescriptiveRoot(input, options = {}) {
736
738
  stylesheet: input.stylesheet,
737
739
  subtitle: input.subtitle,
738
740
  isSeries: rootKind !== "parallel",
739
- transition: rootKind === "transitionSeries" ? resolved.name : void 0,
740
- transitionTime: rootKind === "transitionSeries" ? resolved.time : 0.5,
741
+ transition: root.transition || root.layout === "transitionSeries" ? resolved.name : void 0,
742
+ transitionTime: root.transition || root.layout === "transitionSeries" ? resolved.time : 0.5,
741
743
  children: children.map((c) => c.stream),
742
744
  durationInSeconds: duration
743
745
  };
@@ -10131,7 +10133,6 @@ function preserveVariantAttrs(node2, attrs) {
10131
10133
  "duration",
10132
10134
  "startFrom",
10133
10135
  "endAt",
10134
- "loop",
10135
10136
  "width",
10136
10137
  "height",
10137
10138
  "fit",
@@ -10264,7 +10265,6 @@ function parseNodeLine(content3, lineNum) {
10264
10265
  endAt: attrs.endAt,
10265
10266
  volume: attrs.volume,
10266
10267
  foreground: attrs.foreground,
10267
- loop: attrs.loop,
10268
10268
  instruction: attrs.instruction,
10269
10269
  script: attrs.script,
10270
10270
  visible: attrs.visible,
@@ -10594,6 +10594,9 @@ function applyRootAttrs(root, attrs) {
10594
10594
  case "metadata":
10595
10595
  root.metadata = String(v);
10596
10596
  break;
10597
+ case "seed":
10598
+ root.seed = Number(v);
10599
+ break;
10597
10600
  case "stylesheet":
10598
10601
  root.stylesheet = String(v);
10599
10602
  break;
@@ -11148,7 +11151,7 @@ async function resolveAll2(root, options = {}) {
11148
11151
  variant: options.variants?.[0] ?? "video"
11149
11152
  });
11150
11153
  result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
11151
- const generationSeed = result.seed ?? options.seed;
11154
+ const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2147483647);
11152
11155
  if (options.mediaOutputDir) {
11153
11156
  result = await resolveGeneratedMedia(result, {
11154
11157
  outputDir: options.mediaOutputDir,
@@ -11205,6 +11208,7 @@ async function resolveAndCompile(data, options = {}) {
11205
11208
  ttsCli: options.ttsCli,
11206
11209
  sttCli: options.sttCli,
11207
11210
  subtitleOutputDir: options.subtitleOutputDir,
11211
+ seed: options.seed,
11208
11212
  variants: options.variants
11209
11213
  });
11210
11214
  const compiled = compileDescriptiveRoot(resolved, {
@@ -36,6 +36,9 @@ export interface ResolveAndCompileOptions {
36
36
  /** Separate output dir for the merged subtitles.vtt (e.g., per-variant).
37
37
  * Per-clip VTTs stay in scriptOutputDir; defaults to scriptOutputDir. */
38
38
  subtitleOutputDir?: string;
39
+ /** Global seed for reproducible TTI/TTV generation. Applied when the CLI
40
+ * template contains {seed}. Overrides root.seed if set. */
41
+ seed?: number;
39
42
  /** Variant chain to apply to include nodes (e.g. ["zh", "tiktok"]).
40
43
  * When set, included .md files are parsed with variant awareness. */
41
44
  variants?: string[];
@@ -90,6 +93,7 @@ export async function resolveAndCompile(
90
93
  ttsCli: options.ttsCli,
91
94
  sttCli: options.sttCli,
92
95
  subtitleOutputDir: options.subtitleOutputDir,
96
+ seed: options.seed,
93
97
  variants: options.variants,
94
98
  });
95
99
 
@@ -35,24 +35,33 @@ export function extractScenes(root) {
35
35
 
36
36
  // Format 1: scenes as direct children of root
37
37
  if (root.children?.length && !root.children.find(c => c.name === "scenes" || c.id === "scenes")) {
38
- let offset = 0;
38
+ const overlap = root.transition ? (root.transitionTime ?? 0.5) : 0;
39
+ let offset = 0; // cumulative scene durations (without transition overlap)
39
40
  for (const s of root.children) {
40
41
  if (!(s.type === "folder" || s.type === "scene" || s.children?.length)) continue;
41
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.
42
45
  const leaf = (s.children || []).find(c => c.src && (c.type === "image" || c.type === "video"));
43
- const src2 = leaf || s;
44
- const dur = (src2.end ?? 5) - (src2.start ?? 0);
46
+ let dur = s.durationInSeconds;
47
+ if (!dur || dur <= 0) {
48
+ const src2 = leaf || s;
49
+ dur = (src2.end ?? 5) - (src2.start ?? 0);
50
+ }
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);
45
54
  scenes.push({
46
55
  name: s.name || s.id || "scene",
47
- start: offset,
48
- end: offset + dur,
56
+ start,
57
+ end: start + dur,
49
58
  duration: dur,
50
59
  src: leaf?.src || "",
51
60
  mediaType: leaf?.type || "unknown",
52
61
  });
53
62
  offset += dur;
54
63
  }
55
- totalDuration = offset;
64
+ totalDuration = offset - Math.max(0, (scenes.length - 1)) * overlap;
56
65
  }
57
66
 
58
67
  // Format 2: scenes wrapped in a "scenes" folder
@@ -21,12 +21,6 @@ const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const ROOT = resolve(__dirname, "../..");
23
23
 
24
- const ASPECTS = {
25
- "16x9": { width: 1920, height: 1080 },
26
- "9x16": { width: 1080, height: 1920 },
27
- "1x1": { width: 1080, height: 1080 },
28
- };
29
-
30
24
  /**
31
25
  * How many "Rendered X/Y" lines to skip before printing one.
32
26
  * E.g. 50 means print every 50th frame — for 1860 frames that's ~37 lines.
@@ -52,6 +46,7 @@ markcut CLI — Markdown/JSON → video pipeline
52
46
  npx @lalalic/markcut <command> [options]
53
47
 
54
48
  global options:
49
+ --dev Use development build (unminified React error messages)
55
50
  --show-clis Print all default CLI templates and exit
56
51
 
57
52
  --tti <template> Override default TTI CLI template
@@ -92,7 +87,7 @@ Commands:
92
87
 
93
88
 
94
89
  /**
95
- * Render one aspect ratio with compact progress output.
90
+ * Render a stream tree to an MP4 video with compact progress output.
96
91
  *
97
92
  * In compact mode (default), "Rendered X/Y" lines are shown only every
98
93
  * PROGRESS_INTERVAL frames and at the final frame, drastically reducing
@@ -100,28 +95,49 @@ Commands:
100
95
  *
101
96
  * Use --verbose to see every frame line (original behavior).
102
97
  */
98
+
99
+ /**
100
+ * Resolve a potentially relative source path against baseDir, skipping
101
+ * absolute paths, URLs, and data URIs.
102
+ */
103
+ function resolveAssetPath(src, baseDir) {
104
+ if (!src || typeof src !== "string") return null;
105
+ // Already absolute on disk
106
+ if (src.startsWith("/")) return src;
107
+ // URL or data URI — can't stage locally
108
+ if (/^(https?:|data:|blob:)/.test(src)) return null;
109
+ // Relative path: resolve against the input file's directory
110
+ const abs = resolve(baseDir, src);
111
+ if (existsSync(abs)) return abs;
112
+ return null;
113
+ }
114
+
103
115
  /**
104
- * Stage local absolute-path assets (TTS audio, TTI/TTV media, subtitles from
105
- * .markcut/) into ROOT/public/.render-assets/ and rewrite srcs to relative
106
- * paths, so `npx remotion render` (publicDir = ROOT/public) can serve them.
107
- * The preview server handles these paths via multi-root serving; the render
108
- * CLI needs them staged into the one public dir Remotion knows about.
116
+ * Stage all local src files (absolute and relative) into
117
+ * ROOT/public/.render-assets/ so `npx remotion render` (publicDir = ROOT/public)
118
+ * can serve them. Relative paths are resolved against `baseDir` (the input
119
+ * file's directory). The preview server handles these paths via multi-root
120
+ * serving; the render CLI needs them staged into the one public dir Remotion
121
+ * knows about.
109
122
  */
110
- function stageLocalAssets(tree) {
123
+ function stageLocalAssets(tree, baseDir) {
111
124
  const assetsDir = join(ROOT, "public", ".render-assets");
112
125
  const seen = new Map();
113
126
  const walk = (node) => {
114
127
  if (!node || typeof node !== "object") return;
115
- if (typeof node.src === "string" && node.src.startsWith("/") && !node.src.startsWith("//") && existsSync(node.src)) {
116
- let staged = seen.get(node.src);
117
- if (!staged) {
118
- const name = createHash("md5").update(node.src).digest("hex").slice(0, 12) + extname(node.src);
119
- mkdirSync(assetsDir, { recursive: true });
120
- copyFileSync(node.src, join(assetsDir, name));
121
- staged = `.render-assets/${name}`;
122
- seen.set(node.src, staged);
128
+ if (typeof node.src === "string") {
129
+ const absPath = resolveAssetPath(node.src, baseDir);
130
+ if (absPath) {
131
+ let staged = seen.get(absPath);
132
+ if (!staged) {
133
+ const name = createHash("md5").update(absPath).digest("hex").slice(0, 12) + extname(absPath);
134
+ mkdirSync(assetsDir, { recursive: true });
135
+ copyFileSync(absPath, join(assetsDir, name));
136
+ staged = `.render-assets/${name}`;
137
+ seen.set(absPath, staged);
138
+ }
139
+ node.src = staged;
123
140
  }
124
- node.src = staged;
125
141
  }
126
142
  for (const value of Object.values(node)) {
127
143
  if (value && typeof value === "object") walk(value);
@@ -131,21 +147,24 @@ function stageLocalAssets(tree) {
131
147
  return tree;
132
148
  }
133
149
 
134
- function renderOne(streamTree, aspect, outputPath, verbose) {
135
- const dims = ASPECTS[aspect];
136
- if (!dims) throw new Error(`Unknown aspect: ${aspect}`);
137
-
138
- const adapted = stageLocalAssets(JSON.parse(JSON.stringify({ ...streamTree, width: dims.width, height: dims.height })));
139
- const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
150
+ function renderOne(streamTree, outputPath, verbose, baseDir) {
151
+ const adapted = stageLocalAssets(JSON.parse(JSON.stringify(streamTree)), baseDir);
152
+ const tmpProps = join(ROOT, ".tmp", "render-stream.json");
140
153
  mkdirSync(dirname(tmpProps), { recursive: true });
141
154
  writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
142
155
 
143
156
  mkdirSync(dirname(outputPath), { recursive: true });
144
157
 
145
- console.log(`\n▶ Rendering ${aspect} → ${outputPath}`);
158
+ console.log(`\n▶ Rendering → ${outputPath}`);
146
159
 
147
160
  return new Promise((resolvePromise, reject) => {
148
- const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] });
161
+ // Pass NODE_ENV=development to get unminified React error messages (--dev flag)
162
+ const spawnOpts = { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] };
163
+ // args is imported from config.mjs — check dev flag there
164
+ if (args.dev) {
165
+ spawnOpts.env = { ...process.env, NODE_ENV: "development" };
166
+ }
167
+ const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], spawnOpts);
149
168
 
150
169
  let lastLoggedFrame = 0;
151
170
  let totalFrames = 0;
@@ -401,7 +420,8 @@ edit=${DEFAULT_EDIT_CLI}`);
401
420
  }
402
421
 
403
422
  const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
404
- await renderOne(streamTree, "16x9", output, args.verbose);
423
+ const fileDir = dirname(resolve(args.file));
424
+ await renderOne(streamTree, output, args.verbose, fileDir);
405
425
 
406
426
  console.log("\n✅ Render complete.");
407
427
  process.exit(0);
@@ -441,12 +461,27 @@ function hasScript(root) {
441
461
  emitInfo(`File: ${filePath}`);
442
462
  emitInfo(`Format: ${isMarkdown ? "Markdown" : "JSON"}`);
443
463
 
444
- const { compileDescriptiveRoot, parseMarkdownDescriptive, parseImportsBlock } = await import("../player/pipeline.mjs");
464
+ const { compileDescriptiveRoot, parseMarkdownDescriptive, parseMarkdownVariants, parseImportsBlock } = await import("../player/pipeline.mjs");
445
465
 
446
466
  try {
447
467
  let descriptive, needsTti, needsTtv;
448
468
  if (isMarkdown) {
449
469
  descriptive = parseMarkdownDescriptive(raw);
470
+
471
+ // Check if content accidentally went into a variant section
472
+ // (happens when root heading is e.g. "# My Movie" instead of "# video")
473
+ const hasChildren = (descriptive.children?.length ?? 0) > 0;
474
+ if (!hasChildren) {
475
+ const variantResult = parseMarkdownVariants(raw);
476
+ const variantNames = [...variantResult.variants.keys()];
477
+ const variantChildren = variantNames.filter(n => (variantResult.variants.get(n)?.children?.length ?? 0) > 0);
478
+ if (variantChildren.length > 0) {
479
+ warnings.push(
480
+ `No scenes found in the main section (use '# video' as the root heading). ` +
481
+ `Content appears in variant section(s): ${variantChildren.join(", ")}`,
482
+ );
483
+ }
484
+ }
450
485
  } else {
451
486
  const parsed = JSON.parse(raw);
452
487
  const root = parsed.root ?? parsed;
@@ -104,7 +104,6 @@ export const video = base.extend({
104
104
  playbackRate: z.number().optional(),
105
105
  width: z.number().default(1080),
106
106
  height: z.number().default(1920),
107
- loop: z.number().int().min(1).optional().describe(">1 = loop count"),
108
107
  });
109
108
  export type Video = z.infer<typeof video>;
110
109
 
@@ -113,7 +112,6 @@ export const audio = base.extend({
113
112
  src: z.string().optional(),
114
113
  volume: z.number().min(0).max(1).default(1),
115
114
  foreground: z.boolean().optional().describe("ducks parent video audio while playing"),
116
- loop: z.number().int().min(1).optional().describe(">1 = loop count"),
117
115
  speaker: z.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue"),
118
116
  });
119
117
  export type Audio = z.infer<typeof audio>;
@@ -24,7 +24,7 @@ export function AudioLeaf({ stream }: { stream: Audio }) {
24
24
  const startFrom = stream.startFrom ?? 0;
25
25
  const endAt = stream.endAt ?? totalDur;
26
26
  const volume = stream.volume ?? 1;
27
- const playbackRate = stream.loop ? 1 : toPlaybackRate((endAt - startFrom) / (end - start));
27
+ const playbackRate = toPlaybackRate((endAt - startFrom) / (end - start));
28
28
  return (
29
29
  <Sequence
30
30
  name={stream.src ?? "audio"}
@@ -39,7 +39,6 @@ export function AudioLeaf({ stream }: { stream: Audio }) {
39
39
  endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
40
40
  muted={volume === 0 || !!ctx?.foreground}
41
41
  volume={volume}
42
- loop={(stream.loop ?? 1) > 1}
43
42
  playbackRate={playbackRate}
44
43
  showInTimeline={false}
45
44
  />
@@ -5,15 +5,8 @@ import { cssJS, toClassName, getDurationInSeconds, type DurationStream } from ".
5
5
  import type { Include as IncludeStream, Root } from "../schema/index";
6
6
  import { FolderLeaf } from "./Folder";
7
7
 
8
- // Aspect dimensions for scene-based video content
9
- const ASPECT_DIMS: Record<string, { width: number; height: number }> = {
10
- "16x9": { width: 1920, height: 1080 },
11
- "9x16": { width: 1080, height: 1920 },
12
- "1x1": { width: 1080, height: 1080 },
13
- };
14
-
15
8
  interface SceneBasedVideo {
16
- meta: { title: string; fps: number; aspects?: string[] };
9
+ meta: { title: string; fps: number };
17
10
  voiceover?: { tts: string; voice: string };
18
11
  bgm?: { src: string; baseVolume: number };
19
12
  scenes: Array<{
@@ -207,9 +200,8 @@ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
207
200
  // ── Scene-based video.json ──────────────────────────────────
208
201
  const vj = externalData;
209
202
  const vjFps = vj.meta.fps ?? parentFps;
210
- // Use first aspect's dimensions (default to 16x9)
211
- const aspectKey = (vj.meta.aspects?.[0] ?? "16x9") as keyof typeof ASPECT_DIMS;
212
- const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
203
+ // Use parent composition dimensions (dimensions come from the stream file)
204
+ const dims = { width: parentWidth, height: parentHeight };
213
205
 
214
206
  return (
215
207
  <AbsoluteFill style={{ backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }}>