@lalalic/markcut 2.2.9 → 2.4.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.
package/AGENTS.md CHANGED
@@ -39,7 +39,7 @@ See [docs/markdown-strict-descriptive.md](docs/markdown-strict-descriptive.md) f
39
39
  | `folder` | Internal container (series/parallel) |
40
40
  | `image` | Still photo with fit mode |
41
41
  | `video` | Video clip with startFrom/endAt trimming, playbackRate |
42
- | `audio` | Soundtrack/SFX with foreground ducking, loop |
42
+ | `audio` | Soundtrack/SFX with foreground ducking |
43
43
  | `component` | External React component by componentName + props |
44
44
  | `effect` | CSS keyframe animation wrapper (fadeIn, zoomIn, bounceIn, etc.) |
45
45
  | `include` | Embed external video JSON (file, URL, or data URI) |
@@ -134,8 +134,8 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
134
134
  | `folder` | Group children | `isSeries`, `transition`, `children[]` |
135
135
  | `scene` | Storyboard node (alias for folder) | `name`, `description`, `script`, `children[]` |
136
136
  | `image` | Still photo | `src`, `fit` (contain/cover/fill) |
137
- | `video` | Video clip | `src`, `volume`, `playbackRate`, `loop` |
138
- | `audio` | Soundtrack/SFX | `src`, `volume`, `foreground` (ducks parent), `loop` |
137
+ | `video` | Video clip | `src`, `volume`, `playbackRate` |
138
+ | `audio` | Soundtrack/SFX | `src`, `volume`, `foreground` (ducks parent) |
139
139
  | `subtitle` | Text overlay | `src` (text/VTT) or `cues[]`, `fontSize`, `style` |
140
140
  | `component` | React component | `componentName`, `props`, `src` (remote ESM) |
141
141
  | `effect` | CSS animation wrapper | `animation`, `customKeyframes`, `children[]` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.2.9",
3
+ "version": "2.4.0",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -23,11 +23,6 @@ see [docs/markdown-descriptive.md](docs/markdown-descriptive.md) for full detail
23
23
  ### Video Variants
24
24
  - define variants for different video configurations, such as different languages, aspects, platforms
25
25
 
26
-
27
- ### Templates
28
- - Templates in ``docs/templates/`` provide ready-to-use video structures for `orchestrator` agents, use them as starting point to create your own videos.
29
-
30
-
31
26
  ### Viral Story requires
32
27
  - **Hooks** : why should the viewer watch this?
33
28
  - **Conflict** : what challenges or obstacles do the characters face?
@@ -36,11 +31,22 @@ see [docs/markdown-descriptive.md](docs/markdown-descriptive.md) for full detail
36
31
  - **Call to action** : what should the viewer do next?
37
32
  - **Open ending** : does the story leave room for interpretation or continuation?
38
33
 
34
+ ### TTS, TTI, TTV, STT media generation
35
+ markcut implements a **media generation pipeline** to generate TTS, TTI, TTV, STT media automatically.
36
+ - `npx @lalalic/markcut --show-clis` to see the default CLIs for TTS, TTI, TTV, STT media generation.
37
+ - `orchestrator` agent DONT generate media directly
38
+
39
39
  ## 3. CLI
40
40
 
41
41
  ```bash
42
42
  npx @lalalic/markcut <command> [options]
43
43
  npx @lalalic/markcut --help # get overall information
44
+ npx @lalalic/markcut --show-clis # get command specific information
45
+ npx @lalalic/markcut preview <file> # assemble and preview the video with a local server, and chat to edit the video and auto refresh
46
+ npx @lalalic/markcut render <file> # render the video to mp4
47
+ npx @lalalic/markcut vision <folder> # vision understanding medias in folder
48
+ npx @lalalic/markcut vision <folder> --label # full pipeline vision understanding medias in folder, and provide ui to label the medias with text, time ranges
49
+
44
50
  ```
45
51
 
46
52
  ---
@@ -60,8 +66,6 @@ some common issues (photo or video can't be displayed, audio missing), take belo
60
66
  | Topic | File |
61
67
  |-------|------|
62
68
  | Markdown descriptive format (primary authoring format) | [docs/markdown-descriptive.md](docs/markdown-descriptive.md) |
63
- | Label system (browse, label, export labels.json) | [docs/label-mode.md](docs/label-mode.md) |
64
- | Player servers (label + edit mode) | [docs/edit-mode.md](docs/edit-mode.md) |
65
69
 
66
70
 
67
71
  ## common used components
@@ -79,4 +83,6 @@ some common issues (photo or video can't be displayed, audio missing), take belo
79
83
  - audio cut off
80
84
  - video cut off
81
85
  - subtitle mismatch
82
- - sync issues between audio, video, and subtitles
86
+ - sync issues between audio, video, and subtitles
87
+ - don't set duration for script or stream's duration depending on audio script
88
+ - markcut resolver will automatically calculate the duration based on the audio script length
@@ -165,7 +165,6 @@ For compatibility, `import { Name } from "spec"` also works and produces the sam
165
165
  | `foreground` | bool; ducks parent video audio while playing | audio |
166
166
  | `spots` | number[] beat timestamps | rhythm |
167
167
  | `fit` | `contain\|cover\|fill` | image |
168
- | `loop` | int >1 | audio |
169
168
  | `playbackRate` | number | video |
170
169
  | `jsx` | usage JSX expression (`"<ComA value={42} />"`); compiled at runtime with registered imports in scope | component |
171
170
  | `effects` | `[name, name(params...)]` e.g. `[fadeIn, bounceIn(1, ease-out, 2)]` | any leaf/container | Apply animations directly — no wrapper node needed |
@@ -226,7 +225,7 @@ When: moving footage. Required: `src` + (`duration` or `endAt`).
226
225
 
227
226
  When: voiceover, BGM, SFX, TTS narration. Required: `src` + (`duration` or `endAt`).
228
227
 
229
- `- audio src:bgm.mp3 duration:6 volume:0.4 loop:2`
228
+ `- audio src:bgm.mp3 duration:6 volume:0.4 isBackground`
230
229
 
231
230
  ### Narration / TTS
232
231
 
@@ -760,16 +759,16 @@ markcut verify courseware.md
760
759
  # best practices
761
760
  ~~~ example - avoid audio cut
762
761
  - parallel
763
- - image|video background:true
762
+ - image|video isBackground:true
764
763
  - audio|script
765
764
  ~~~
766
765
 
767
766
  ~~~ example - global streams
768
767
  #video
769
- - audio background:true loop:true src:bgm.mp3
770
- - component background:true loop:true jsx:"<Logo />" style:"position:fixed;top:10px;left:10px;width:100px;height:100px;"
768
+ - audio isBackground:true src:bgm.mp3
769
+ - component isBackground:true jsx:"<Logo />" style:"position:fixed;top:10px;left:10px;width:100px;height:100px;"
771
770
  - parallel title:"lip sync" style:"position:fixed;bottom:100px;right:100px;width:100px;height:100px;"
772
- - video src:background.mp4 loop:true background:true
771
+ - video src:background.mp4 isBackground:true
773
772
  - video src:lip_sync.mp4
774
773
  ~~~
775
774
 
@@ -803,3 +802,13 @@ And now it's <span class="emoji-dead">dead</span>.
803
802
  ```
804
803
  ~~~
805
804
 
805
+ ~~~ example - sync vision and audio, taking care of transition timing, dynamic audio duration
806
+ #video
807
+ layout:series transition:fade transitionTime:1.2
808
+
809
+ ## hello layout:parallel
810
+ - image src:vision.jpg isBackground:true # set vision as background let script/audio play in front determine the duration of the scene
811
+ - script "..." # don't set duration for script/audio, markcut resolver will determine the duration of the scene based on the final audio length
812
+ start:1.2 # let vision play for 1.2s before the script/audio start, so that the transition effect can be completed
813
+
814
+ ~~~
@@ -527,7 +527,6 @@ function compileLeaf(node: Exclude<DescriptiveNode, DescriptiveContainer | Descr
527
527
  src: node.src,
528
528
  volume: node.volume ?? 1,
529
529
  foreground: node.foreground,
530
- loop: node.loop,
531
530
  speaker: node.speaker,
532
531
  };
533
532
  return { stream, duration: end };
@@ -1182,8 +1181,13 @@ export function resolveVariantOverrides(
1182
1181
  export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileOptions = {}): Root {
1183
1182
  const root: DescriptiveRoot = typeof input === "string" ? JSON.parse(input) : input;
1184
1183
 
1185
- const resolved = resolveTransition(root.layout ? root.transition ?? root.layout : root.transition, root.transitionTime);
1186
- const rootKind: "series" | "parallel" | "transitionSeries" = root.layout === "parallel" ? "parallel" : "series";
1184
+ const resolved = resolveTransition(
1185
+ root.layout === "transitionSeries" ? (root.transition ?? "fade") : root.transition,
1186
+ root.transitionTime,
1187
+ );
1188
+ const rootKind: "series" | "parallel" | "transitionSeries" =
1189
+ root.layout === "parallel" ? "parallel" :
1190
+ root.layout === "transitionSeries" ? "transitionSeries" : "series";
1187
1191
 
1188
1192
  const googleMapsApiKey = options.googleMapsApiKey ?? "";
1189
1193
  const ctx: CompileContext = {
@@ -1215,8 +1219,8 @@ export function compileDescriptiveRoot(input: DescriptiveRoot, options: CompileO
1215
1219
  stylesheet: input.stylesheet,
1216
1220
  subtitle: input.subtitle,
1217
1221
  isSeries: rootKind !== "parallel",
1218
- transition: rootKind === "transitionSeries" ? resolved.name : undefined,
1219
- transitionTime: rootKind === "transitionSeries" ? resolved.time : 0.5,
1222
+ transition: root.transition || root.layout === "transitionSeries" ? resolved.name : undefined,
1223
+ transitionTime: root.transition || root.layout === "transitionSeries" ? resolved.time : 0.5,
1220
1224
  children: children.map((c) => c.stream),
1221
1225
  durationInSeconds: duration,
1222
1226
  };
@@ -221,7 +221,6 @@ function parseNodeLine(content: string, lineNum?: number): DescriptiveNode {
221
221
  endAt: attrs.endAt as any,
222
222
  volume: attrs.volume as any,
223
223
  foreground: attrs.foreground as any,
224
- loop: attrs.loop as any,
225
224
  instruction: attrs.instruction as any,
226
225
  script: attrs.script as any,
227
226
  visible: attrs.visible as any,
@@ -633,6 +632,9 @@ function applyRootAttrs(root: DescriptiveRoot, attrs: Record<string, unknown>):
633
632
  case "metadata":
634
633
  root.metadata = String(v);
635
634
  break;
635
+ case "seed":
636
+ root.seed = Number(v);
637
+ break;
636
638
  case "stylesheet":
637
639
  root.stylesheet = String(v);
638
640
  break;
@@ -986,7 +986,8 @@ export async function resolveAll(
986
986
 
987
987
  // Step 3: Generate images/videos from prompts before probing durations.
988
988
  // Use root.seed if set; otherwise inherit from options (for includes).
989
- const generationSeed = result.seed ?? options.seed;
989
+ // Auto-generate a random seed so {seed} in the CLI template always gets a value.
990
+ const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2_147_483_647);
990
991
  if (options.mediaOutputDir) {
991
992
  result = await resolveGeneratedMedia(result, {
992
993
  outputDir: options.mediaOutputDir,
@@ -136,14 +136,16 @@ function PlayerApp() {
136
136
  .catch(() => {});
137
137
  }, []);
138
138
 
139
- // Update active scene from currentTime
139
+ // Update active scene from currentTime.
140
+ // Iterate backward so the latest scene whose start <= currentTime wins —
141
+ // during transition overlaps, the newer scene takes precedence.
140
142
  React.useEffect(() => {
141
143
  const scenes = (window as any).__scenes;
142
144
  if (!scenes) return;
143
145
  let found = "";
144
- for (const s of scenes) {
145
- if (currentTime >= s.start && currentTime < s.end) {
146
- found = s.name || "";
146
+ for (let i = scenes.length - 1; i >= 0; i--) {
147
+ if (currentTime >= scenes[i].start) {
148
+ found = scenes[i].name || "";
147
149
  break;
148
150
  }
149
151
  }
@@ -213,14 +215,28 @@ function PlayerApp() {
213
215
  }, [ready, data, startAt, fps]);
214
216
 
215
217
  // ── onFrameUpdate: track current time ───────────────────────────────
218
+ // Remotion Player 4.x dispatches `frameupdate` events via the player ref's
219
+ // EventTarget API — there is no `onFrameUpdate` prop. Subscribe in an effect.
216
220
  const handleFrameUpdate = React.useCallback((frame: number) => {
217
221
  currentFrameRef.current = frame;
218
- // Throttle state updates to ~2fps for scene tracking
222
+ // Throttle state updates lightly for scene tracking
219
223
  setCurrentTime(prev => {
220
- const newTime = frame / (data?.fps ?? 30);
221
- return Math.abs(newTime - prev) > 0.5 ? newTime : prev;
224
+ const newTime = frame / fps;
225
+ return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
222
226
  });
223
- }, [data?.fps]);
227
+ }, [fps]);
228
+
229
+ // Subscribe to frameupdate events on the player ref (after it mounts)
230
+ React.useEffect(() => {
231
+ const p = playerRef.current;
232
+ if (!p || typeof (p as any).addEventListener !== "function") return;
233
+ const listener = (e: any) => {
234
+ const frame = e?.detail?.frame;
235
+ if (typeof frame === "number") handleFrameUpdate(frame);
236
+ };
237
+ (p as any).addEventListener("frameupdate", listener);
238
+ return () => (p as any).removeEventListener("frameupdate", listener);
239
+ }, [ready, data, handleFrameUpdate]);
224
240
 
225
241
  // Keyboard shortcuts
226
242
  React.useEffect(() => {
@@ -233,14 +249,17 @@ function PlayerApp() {
233
249
  function seekRelative(deltaSec: number) {
234
250
  const p = playerRef.current;
235
251
  if (!p) return;
236
- const frame = p.getCurrentFrame() + Math.round(deltaSec * fps);
237
- p.seekTo(Math.max(0, frame));
252
+ const frame = Math.max(0, p.getCurrentFrame() + Math.round(deltaSec * fps));
253
+ p.seekTo(frame);
254
+ setCurrentTime(frame / fps);
238
255
  }
239
256
 
240
257
  function seekPercent(pct: number) {
241
258
  const p = playerRef.current;
242
259
  if (!p) return;
243
- p.seekTo(Math.round(pct * durationInFrames));
260
+ const frame = Math.round(pct * durationInFrames);
261
+ p.seekTo(frame);
262
+ setCurrentTime(frame / fps);
244
263
  }
245
264
 
246
265
  function showHelp() {
@@ -439,7 +458,6 @@ function PlayerApp() {
439
458
  clickToPlay={false}
440
459
  doubleClickToFullscreen={true}
441
460
  autoPlay={autoPlay}
442
- onFrameUpdate={handleFrameUpdate}
443
461
  />
444
462
  </div>
445
463
 
@@ -448,8 +466,9 @@ function PlayerApp() {
448
466
  currentTime={currentTime}
449
467
  onSeek={(t) => {
450
468
  if (playerRef.current) {
451
- const frame = Math.round(t * (data?.fps ?? 30));
469
+ const frame = Math.round(t * fps);
452
470
  playerRef.current.seekTo(frame);
471
+ setCurrentTime(t);
453
472
  }
454
473
  }}
455
474
  />
@@ -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
  }
@@ -45891,15 +45890,41 @@ function supportHtml(text) {
45891
45890
  });
45892
45891
  return el;
45893
45892
  }
45893
+ function stripHtml(text) {
45894
+ return text.replace(/<[^>]*>/g, "").trim();
45895
+ }
45896
+ function groupConsecutiveCues(cues) {
45897
+ const groups = [];
45898
+ let current2 = [];
45899
+ for (const cue of cues) {
45900
+ const plain = stripHtml(cue.text);
45901
+ if (current2.length === 0) {
45902
+ current2.push(cue);
45903
+ } else {
45904
+ const prevPlain = stripHtml(current2[current2.length - 1].text);
45905
+ if (plain === prevPlain) {
45906
+ current2.push(cue);
45907
+ } else {
45908
+ groups.push(current2);
45909
+ current2 = [cue];
45910
+ }
45911
+ }
45912
+ }
45913
+ if (current2.length > 0) groups.push(current2);
45914
+ return groups;
45915
+ }
45894
45916
  function CueFrame({
45895
45917
  cue,
45896
45918
  fps,
45897
45919
  CaptionComponent,
45898
- subtitle
45920
+ subtitle,
45921
+ cueGroup
45899
45922
  }) {
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]);
45923
+ const group = cueGroup ?? [cue];
45924
+ const startFrom = group[0].startFrom;
45925
+ const endAt = group[group.length - 1].endAt;
45926
+ const durationInFrames = Math.max(1, Math.floor((endAt - startFrom) * fps));
45927
+ const from = Math.floor(startFrom * fps);
45903
45928
  const textStyle = React46.useMemo(
45904
45929
  () => ({
45905
45930
  ...DEFAULT_TEXT_STYLE,
@@ -45909,7 +45934,33 @@ function CueFrame({
45909
45934
  }),
45910
45935
  [subtitle.fontSize, subtitle.fontFamily, subtitle.fontStyle]
45911
45936
  );
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 }) });
45937
+ return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(Sequence, { layout: "none", durationInFrames, from, children: /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
45938
+ GroupedCueInner,
45939
+ {
45940
+ group,
45941
+ fps,
45942
+ CaptionComponent,
45943
+ textStyle
45944
+ }
45945
+ ) });
45946
+ }
45947
+ function GroupedCueInner({
45948
+ group,
45949
+ fps,
45950
+ CaptionComponent,
45951
+ textStyle
45952
+ }) {
45953
+ const frame = useCurrentFrame();
45954
+ const currentTime = frame / fps;
45955
+ let activeCue = group[group.length - 1];
45956
+ for (const c3 of group) {
45957
+ if (currentTime >= c3.startFrom && currentTime < c3.endAt) {
45958
+ activeCue = c3;
45959
+ break;
45960
+ }
45961
+ }
45962
+ const captionText = React46.useMemo(() => supportHtml(activeCue.text), [activeCue.text]);
45963
+ return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(CaptionComponent, { text: captionText, style: textStyle });
45913
45964
  }
45914
45965
  function SubtitleOverlay({ subtitle }) {
45915
45966
  const { fps } = useVideoConfig();
@@ -45955,15 +46006,17 @@ function SubtitleOverlay({ subtitle }) {
45955
46006
  }, [subtitle.src]);
45956
46007
  if (!cues) return null;
45957
46008
  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)(
46009
+ const cueGroups = React46.useMemo(() => groupConsecutiveCues(cues), [cues]);
46010
+ 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
46011
  CueFrame,
45960
46012
  {
45961
- cue,
46013
+ cue: group[0],
46014
+ cueGroup: group,
45962
46015
  fps,
45963
46016
  CaptionComponent,
45964
46017
  subtitle
45965
46018
  },
45966
- `${i3}-${cue.startFrom}-${cue.endAt}`
46019
+ `g${gi}-${group[0].startFrom}-${group[group.length - 1].endAt}`
45967
46020
  )) });
45968
46021
  }
45969
46022
 
@@ -59804,15 +59857,13 @@ var video = base.extend({
59804
59857
  volume: external_exports.number().min(0).max(1).default(1),
59805
59858
  playbackRate: external_exports.number().optional(),
59806
59859
  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")
59860
+ height: external_exports.number().default(1920)
59809
59861
  });
59810
59862
  var audio = base.extend({
59811
59863
  type: external_exports.literal("audio").default("audio"),
59812
59864
  src: external_exports.string().optional(),
59813
59865
  volume: external_exports.number().min(0).max(1).default(1),
59814
59866
  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
59867
  speaker: external_exports.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue")
59817
59868
  });
59818
59869
  var image = base.extend({
@@ -60361,8 +60412,8 @@ function SceneThumbnails({ currentTime, onSeek }) {
60361
60412
  }, []);
60362
60413
  if (scenes.length === 0) return null;
60363
60414
  let activeIdx = -1;
60364
- for (let i3 = 0; i3 < scenes.length; i3++) {
60365
- if (currentTime >= scenes[i3].start && currentTime < scenes[i3].end) {
60415
+ for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
60416
+ if (currentTime >= scenes[i3].start) {
60366
60417
  activeIdx = i3;
60367
60418
  break;
60368
60419
  }
@@ -60498,9 +60549,9 @@ function PlayerApp() {
60498
60549
  const scenes = window.__scenes;
60499
60550
  if (!scenes) return;
60500
60551
  let found = "";
60501
- for (const s2 of scenes) {
60502
- if (currentTime >= s2.start && currentTime < s2.end) {
60503
- found = s2.name || "";
60552
+ for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
60553
+ if (currentTime >= scenes[i3].start) {
60554
+ found = scenes[i3].name || "";
60504
60555
  break;
60505
60556
  }
60506
60557
  }
@@ -60555,10 +60606,20 @@ function PlayerApp() {
60555
60606
  const handleFrameUpdate = React55.useCallback((frame) => {
60556
60607
  currentFrameRef.current = frame;
60557
60608
  setCurrentTime((prev) => {
60558
- const newTime = frame / (data2?.fps ?? 30);
60559
- return Math.abs(newTime - prev) > 0.5 ? newTime : prev;
60609
+ const newTime = frame / fps;
60610
+ return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
60560
60611
  });
60561
- }, [data2?.fps]);
60612
+ }, [fps]);
60613
+ React55.useEffect(() => {
60614
+ const p2 = playerRef.current;
60615
+ if (!p2 || typeof p2.addEventListener !== "function") return;
60616
+ const listener = (e) => {
60617
+ const frame = e?.detail?.frame;
60618
+ if (typeof frame === "number") handleFrameUpdate(frame);
60619
+ };
60620
+ p2.addEventListener("frameupdate", listener);
60621
+ return () => p2.removeEventListener("frameupdate", listener);
60622
+ }, [ready, data2, handleFrameUpdate]);
60562
60623
  React55.useEffect(() => {
60563
60624
  if (!ready || !playerRef.current) return;
60564
60625
  const FWD_SECONDS = 5;
@@ -60567,13 +60628,16 @@ function PlayerApp() {
60567
60628
  function seekRelative(deltaSec) {
60568
60629
  const p2 = playerRef.current;
60569
60630
  if (!p2) return;
60570
- const frame = p2.getCurrentFrame() + Math.round(deltaSec * fps);
60571
- p2.seekTo(Math.max(0, frame));
60631
+ const frame = Math.max(0, p2.getCurrentFrame() + Math.round(deltaSec * fps));
60632
+ p2.seekTo(frame);
60633
+ setCurrentTime(frame / fps);
60572
60634
  }
60573
60635
  function seekPercent(pct) {
60574
60636
  const p2 = playerRef.current;
60575
60637
  if (!p2) return;
60576
- p2.seekTo(Math.round(pct * durationInFrames));
60638
+ const frame = Math.round(pct * durationInFrames);
60639
+ p2.seekTo(frame);
60640
+ setCurrentTime(frame / fps);
60577
60641
  }
60578
60642
  function showHelp() {
60579
60643
  console.log(`%c\u{1F3AC} MarkCut Player Shortcuts
@@ -60746,8 +60810,7 @@ function PlayerApp() {
60746
60810
  allowFullscreen: true,
60747
60811
  clickToPlay: false,
60748
60812
  doubleClickToFullscreen: true,
60749
- autoPlay,
60750
- onFrameUpdate: handleFrameUpdate
60813
+ autoPlay
60751
60814
  }
60752
60815
  ) }),
60753
60816
  /* @__PURE__ */ (0, import_jsx_runtime95.jsx)(
@@ -60756,8 +60819,9 @@ function PlayerApp() {
60756
60819
  currentTime,
60757
60820
  onSeek: (t) => {
60758
60821
  if (playerRef.current) {
60759
- const frame = Math.round(t * (data2?.fps ?? 30));
60822
+ const frame = Math.round(t * fps);
60760
60823
  playerRef.current.seekTo(frame);
60824
+ setCurrentTime(t);
60761
60825
  }
60762
60826
  }
60763
60827
  }
@@ -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
@@ -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
  />
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { cancelRender, continueRender, delayRender, Sequence, staticFile, useVideoConfig } from "remotion";
2
+ import { cancelRender, continueRender, delayRender, Sequence, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
3
  import * as Subtitles from "remotion-subtitle";
4
4
  import { cssJS, parseVTT, type Cue } from "../utils/index";
5
5
  import type { SubtitleOverlay as SubtitleOverlayConfig } from "../schema/index";
@@ -89,28 +89,79 @@ function supportHtml(text: string): any {
89
89
  return el;
90
90
  }
91
91
 
92
+ /**
93
+ * Strip HTML tags from text, returning the plain text content.
94
+ * Used to compare cue texts ignoring word-level highlighting markup.
95
+ */
96
+ function stripHtml(text: string): string {
97
+ return text.replace(/<[^>]*>/g, "").trim();
98
+ }
99
+
100
+ /**
101
+ * Group consecutive cues that share the same plain-text content.
102
+ * This is the pattern used by word-level highlighting VTT where each
103
+ * cue contains the full sentence but wraps a different word in HTML
104
+ * (e.g. `<u>` or `<span style="...">`).
105
+ *
106
+ * Grouping them into a single CueFrame keeps the caption component
107
+ * mounted across word transitions, eliminating the flash that occurs
108
+ * when each cue unmounts/remounts its own Sequence.
109
+ */
110
+ function groupConsecutiveCues(cues: Cue[]): Cue[][] {
111
+ const groups: Cue[][] = [];
112
+ let current: Cue[] = [];
113
+
114
+ for (const cue of cues) {
115
+ const plain = stripHtml(cue.text);
116
+ if (current.length === 0) {
117
+ current.push(cue);
118
+ } else {
119
+ const prevPlain = stripHtml(current[current.length - 1]!.text);
120
+ if (plain === prevPlain) {
121
+ current.push(cue);
122
+ } else {
123
+ groups.push(current);
124
+ current = [cue];
125
+ }
126
+ }
127
+ }
128
+ if (current.length > 0) groups.push(current);
129
+ return groups;
130
+ }
131
+
92
132
  /**
93
133
  * Renders a single cue inside a <Sequence> so Remotion only evaluates it
94
134
  * during the cue's active time window — matching the qili-ai pattern.
95
135
  * This avoids per-frame cue lookups and lets the caption component mount
96
136
  * once per cue rather than re-creating on every frame.
137
+ *
138
+ * When a cue group has multiple entries (same plain text, different HTML
139
+ * highlighting), the component uses useCurrentFrame() to select the
140
+ * active cue's text dynamically — keeping the caption component mounted
141
+ * across word transitions and preventing flash.
97
142
  */
98
143
  function CueFrame({
99
144
  cue,
100
145
  fps,
101
146
  CaptionComponent,
102
147
  subtitle,
148
+ cueGroup,
103
149
  }: {
104
150
  cue: Cue;
105
151
  fps: number;
106
152
  CaptionComponent: React.FC<{ text: any; style?: React.CSSProperties }>;
107
153
  subtitle: SubtitleOverlayConfig;
154
+ /** Optional full group of cues sharing the same plain text. When provided,
155
+ * the Sequence spans the entire group and highlights change dynamically. */
156
+ cueGroup?: Cue[];
108
157
  }) {
109
- const durationInFrames = Math.max(1, Math.floor((cue.endAt - cue.startFrom) * fps));
110
- const from = Math.floor(cue.startFrom * fps);
158
+ const group = cueGroup ?? [cue];
159
+ const startFrom = group[0]!.startFrom;
160
+ const endAt = group[group.length - 1]!.endAt;
161
+
162
+ const durationInFrames = Math.max(1, Math.floor((endAt - startFrom) * fps));
163
+ const from = Math.floor(startFrom * fps);
111
164
 
112
- // Stable reference: compute once per cue, not every frame
113
- const captionText = React.useMemo(() => supportHtml(cue.text), [cue.text]);
114
165
  const textStyle: React.CSSProperties = React.useMemo(
115
166
  () => ({
116
167
  ...DEFAULT_TEXT_STYLE,
@@ -123,17 +174,56 @@ function CueFrame({
123
174
 
124
175
  return (
125
176
  <Sequence layout="none" durationInFrames={durationInFrames} from={from}>
126
- <CaptionComponent text={captionText} style={textStyle} />
177
+ <GroupedCueInner
178
+ group={group}
179
+ fps={fps}
180
+ CaptionComponent={CaptionComponent}
181
+ textStyle={textStyle}
182
+ />
127
183
  </Sequence>
128
184
  );
129
185
  }
130
186
 
187
+ /**
188
+ * Inner component that renders inside the single Sequence.
189
+ * Uses useCurrentFrame() to pick which cue's HTML text to display
190
+ * based on the current time, keeping the caption component mounted
191
+ * across word transitions.
192
+ */
193
+ function GroupedCueInner({
194
+ group,
195
+ fps,
196
+ CaptionComponent,
197
+ textStyle,
198
+ }: {
199
+ group: Cue[];
200
+ fps: number;
201
+ CaptionComponent: React.FC<{ text: any; style?: React.CSSProperties }>;
202
+ textStyle: React.CSSProperties;
203
+ }) {
204
+ const frame = useCurrentFrame();
205
+ const currentTime = frame / fps;
206
+
207
+ // Find the active cue based on current time
208
+ let activeCue = group[group.length - 1]!;
209
+ for (const c of group) {
210
+ if (currentTime >= c.startFrom && currentTime < c.endAt) {
211
+ activeCue = c;
212
+ break;
213
+ }
214
+ }
215
+
216
+ const captionText = React.useMemo(() => supportHtml(activeCue.text), [activeCue.text]);
217
+
218
+ return <CaptionComponent text={captionText} style={textStyle} />;
219
+ }
220
+
131
221
  /**
132
222
  * Root-level subtitle overlay. Renders a single VTT file as captions over
133
223
  * the entire video. VTT cue timestamps are absolute (seconds from video start),
134
224
  * matching useCurrentFrame() / fps directly.
135
225
  *
136
- * Each cue is rendered as a separate <Sequence> (qili-ai pattern) so Remotion
226
+ * Each cue is rendered as a separate <Sequence> so Remotion
137
227
  * only mounts/evaluates the caption component during its active time window.
138
228
  * This avoids per-frame computation for inactive cues.
139
229
  *
@@ -202,12 +292,17 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
202
292
 
203
293
  const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
204
294
 
295
+ // Group consecutive cues with same plain text (word-level highlighting pattern)
296
+ // so the caption component stays mounted across word transitions — no flash.
297
+ const cueGroups = React.useMemo(() => groupConsecutiveCues(cues), [cues]);
298
+
205
299
  return (
206
300
  <div className={`${subtitle.type || "default"} subtitle-overlay`} style={{ ...DEFAULT_BOX_STYLE, ...boxCss }}>
207
- {cues.map((cue, i) => (
301
+ {cueGroups.map((group, gi) => (
208
302
  <CueFrame
209
- key={`${i}-${cue.startFrom}-${cue.endAt}`}
210
- cue={cue}
303
+ key={`g${gi}-${group[0]!.startFrom}-${group[group.length - 1]!.endAt}`}
304
+ cue={group[0]!}
305
+ cueGroup={group}
211
306
  fps={fps}
212
307
  CaptionComponent={CaptionComponent}
213
308
  subtitle={subtitle}
@@ -23,7 +23,7 @@ export function VideoLeaf({ stream }: { stream: Video }) {
23
23
  const startFrom = stream.startFrom ?? 0;
24
24
  const endAt = stream.endAt ?? totalDur;
25
25
  const volume = stream.volume ?? 1;
26
- const playbackRate = stream.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
26
+ const playbackRate = Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
27
27
  const streamStyle = cssJS(stream.style);
28
28
  const hasAnimation = "animation" in streamStyle;
29
29
  return (
@@ -1,220 +0,0 @@
1
- # Watch Mode
2
-
3
- > `preview <file.md> --edit` — live editing loop with AI-assisted JSON editing.
4
-
5
- Edit mode gives you a browser player that **automatically reloads** when the source file changes, plus an **edit input** that calls an AI coding agent (pi) to make changes on your behalf.
6
-
7
- Generated artifacts (TTS audio, compiled includes, component bundles) are
8
- stored under `.markcut/` next to the source file — see [SKILL.md](../SKILL.md) for the full layout.
9
-
10
- ## Quickstart
11
-
12
- ```bash
13
- cd remotion-engine
14
- npm run preview -- sample-visual.json --edit
15
- # → Browser opens, terminal blocks until you close it
16
- ```
17
-
18
- ## Architecture
19
-
20
- ```
21
- ┌─────────────┐ HTTP ┌──────────────────┐ spawn ┌──────────┐
22
- │ Browser │◄──────────────►│ player/server │◄────────────►│ pi CLI │
23
- │ (Chrome) │ SSE │ .mjs (Node) │ one-shot │ (agent) │
24
- └──────┬──────┘ └────────┬─────────┘ └──────────┘
25
- │ │
26
- │ POST /api/edit │ fs.watchFile
27
- │ {text: "make text bigger"} │ (poll every 500ms)
28
- │ │
29
- │ GET /api/video-data ▼
30
- │ ← stream tree JSON ┌──────────────┐
31
- │ │ stream tree │
32
- │ EventSource │ .json (file) │
33
- │ /api/events └──────────────┘
34
- │ ← {type:"reload"} ▲
35
- │ │
36
- │ window.dispatchEvent │ pi edits
37
- │ ("refresh-player") │ this file
38
- │ │
39
- ```
40
-
41
- ## File: `src/player/server.mjs`
42
-
43
- ### Server startup (`cli.mjs` → `server.mjs`)
44
-
45
- ```
46
- node src/render/cli.mjs preview file.json --edit --port 3031
47
- ```
48
-
49
- 1. `cli.mjs` spawns `server.mjs` with `--edit` flag
50
- 2. `server.mjs` parses `file.json` to build a recursive tree description
51
- 3. Starts `fs.watchFile(file.json, {interval:500})` — polls every 500ms for changes
52
- 4. Starts HTTP server on specified port
53
- 5. Auto-opens browser via `open http://localhost:PORT`
54
-
55
- ### Page served
56
-
57
- The HTML page loads two scripts:
58
-
59
- | Script | Source | Role |
60
- |--------|--------|------|
61
- | `player.js` | Bundled esbuild output from `src/player/browser.tsx` | Renders the stream tree using `@remotion/player` with `MarkCut` |
62
- | Inline script | Generated by `getHtml()` in `server.mjs` | Watch-mode UI controls (close button, edit input, SSE) |
63
-
64
- ### Edit flow
65
-
66
- ```
67
- Browser Server pi CLI
68
- │ │ │
69
- │── POST /api/edit ────────────► │ │
70
- │ {text: "make text bigger"} │ │
71
- │ │── spawn("pi", ["-p", prompt])│
72
- │ │ prompt includes: │
73
- │ │ • full tree description │
74
- │ │ (recursive, any depth) │
75
- │ │ • edit history │
76
- │ │ • knowledge (all stream │
77
- │ │ types, components, │
78
- │ │ schema, styling guide) │
79
- │ │ • edit request │
80
- │ │ │
81
- │ │ pi can edit ANY field on │
82
- │ │ ANY node in the tree │
83
- │ │ ├── edits file.json
84
- │ │ └── exits
85
- │ │◄── exit code 0 │
86
- │◄── {done:true, output:"..."} ──┤ │
87
- │ │ │
88
- │ suppressReload = true │ │
89
- │ header shows "Changed text..." │ │
90
- │ │ │
91
- │ (fs.watchFile fires SSE │ │
92
- │ but suppressReload=true │ │
93
- │ so it's ignored) │ │
94
- │ │ │
95
- │ after 4s: │ │
96
- │ dispatchEvent("refresh-player")│ │
97
- │ → player re-fetches /api/data │ │
98
- │ → re-renders with new JSON │ │
99
- ```
100
-
101
- ### Key design decisions
102
-
103
- **Why in-place refresh instead of `location.reload()`?**
104
- - Timeline playback continues uninterrupted
105
- - Status text persists until next edit
106
- - No page flash
107
-
108
- **Why `suppressReload`?**
109
- - SSE fires ~500ms after pi edits the file
110
- - API response arrives at the same time
111
- - `suppressReload` flag ignores the SSE event during edit
112
- - The client reloads itself after showing the summary
113
-
114
- ## File: `src/player/browser.tsx`
115
-
116
- The bundled player app:
117
-
118
- 1. Fetches `/api/video-data` on mount
119
- 2. Renders the stream tree using `@remotion/player` with `MarkCut`
120
- 3. Listens for `"refresh-player"` custom DOM event
121
- 4. On refresh event: re-fetches data, re-renders in-place
122
-
123
- ## File: `src/render/cli.mjs`
124
-
125
- The CLI routes `--edit` flag to the player server:
126
-
127
- ```javascript
128
- if (args.edit) {
129
- const serverArgs = ["src/player/server.mjs", file, "--edit", `--port=${port}`];
130
- const child = spawn("node", serverArgs, { cwd: ROOT, stdio: "inherit" });
131
- // ...blocks until server exits...
132
- }
133
- ```
134
-
135
- ## API Endpoints
136
-
137
- | Endpoint | Method | Description |
138
- |----------|--------|-------------|
139
- | `/api/video-data` | GET | Returns the raw stream tree JSON file |
140
- | `/api/scenes` | GET | Returns parsed scenes array with timing |
141
- | `/api/video-info` | GET | Returns scenes + totalDuration + mode flags |
142
- | `/api/edit` | POST | Accepts `{text}` → spawns pi to edit the JSON → file watcher triggers → SSE reload |
143
- | `/api/shutdown` | POST | Kills the server process, unblocking the agent |
144
- | `/api/events` | GET | SSE stream — sends `{type:"reload"}` when file changes |
145
- | `/api/labels` | GET/POST | (label mode only) Save/load scene labels |
146
-
147
- ## The pi prompt
148
-
149
- Each edit call sends a structured prompt with a **recursive tree description** instead of a flat scene list:
150
-
151
- ```
152
- You are editing sample-visual.json, a Remotion stream tree JSON.
153
-
154
- The stream tree (indentation shows nesting; timing in seconds):
155
- root "root" (1080×1920, 30fps, series, transition:fade)
156
- folder "scene-1" (parallel)
157
- component GradientBackground {type:"radial",animated:true,noise:true} [05s, bg]
158
- component AnimatedHeadline {"text":"Hello Remotion",...} [0.54.5s]
159
- folder "scene-2" (parallel)
160
- component GradientBackground {type:"linear",animated:true} [05s, bg]
161
- subtitle "This text is styled with CSS" fontSize:52 [0.54.5s]
162
- ...
163
-
164
- Previous edits:
165
- 1. make scene 1 headline text orange
166
-
167
- Edit request: make scene 1 headline text bigger
168
-
169
- --- Knowledge ---
170
- You can edit ANY field on ANY node in the JSON...
171
- Stream types: root, folder, video, audio, image, subtitle, component, effect, rhythm, map
172
- ...
173
- IMPORTANT: Read the full existing JSON file before editing. Only edit the JSON file. You can change, add, or remove any field on any node. Output ONLY a one-line summary of what specific change you made.
174
- ```
175
-
176
- The tree description walks the JSON recursively, showing:
177
- - **Nesting depth** via indentation
178
- - **Type** and **id/name** of each node
179
- - **Key fields** (dimensions, fps, theme, series/parallel, transitions)
180
- - **Timing** from leaf span (`[start→end]`)
181
- - **isBackground** flag (`[bg]`)
182
- - **Component props** and node-specific fields (fontSize, fit, style, volume, etc.)
183
-
184
- This means pi can edit **any aspect of the JSON** ÔÇö not just scene props. Nested folders, effects, rhythms, maps, actions, styles, themes, global stylesheets, audio config ÔÇö everything is visible and editable.
185
-
186
- ## Session context
187
-
188
- The server maintains `editHistory[]` — every edit is appended. On each call, previous edits are included so pi knows what was done before:
189
-
190
- ```
191
- Previous edits:
192
- 1. make scene 1 headline text orange
193
- 2. change background from radial to linear gradient
194
- 3. add 3D angle to device mockup in scene 4
195
- ```
196
-
197
- This gives pi enough context to make coherent changes across multiple edit rounds.
198
-
199
- ## Close / tab-close
200
-
201
- | Action | What happens |
202
- |--------|-------------|
203
- | Click **✕** | `sendBeacon("/api/shutdown")` → server exits → agent unblocks |
204
- | Close **tab** | SSE connection drops → 3s grace → server exits |
205
- | Close **browser** | Same as tab close |
206
-
207
- ## Label mode (`--label`)
208
-
209
- `preview <file.json> --label` starts the same player but with a **label input** instead of the edit input. Labels are saved to `labels.json` next to the source file. No AI editing, no file watching.
210
-
211
- ## Build
212
-
213
- The browser bundle is built with esbuild:
214
-
215
- ```bash
216
- bash scripts/build-player.sh
217
- # → src/player/bundle/player.js
218
- ```
219
-
220
- The bundle includes React, @remotion/player, MarkCut, all 13 built-in components, themes, templates, and the duration utility. It is served as a static file by the player server.
@@ -1,112 +0,0 @@
1
- # Label System
2
-
3
- Interactive media labeling before understanding clips in json stream tree.
4
- The stream tree follow rules, see [docs/json-descriptive.md](docs/json-descriptive.md) for full details .
5
-
6
- ## Overview
7
-
8
- The label player lets you load a stream tree, browse its clips, and attach descriptive labels to each clip's media.
9
- The output is an updated `labels.json` file with `description` fields populated.
10
-
11
- ## Usage
12
-
13
- ```bash
14
- # Label from a stream tree JSON or labels.json
15
- npx markcut preview labels.json --label
16
-
17
- # Specify port
18
- npx markcut preview labels.json --label --port 3031
19
- ```
20
-
21
- ### Labels Persistence
22
- The stream tree follow rules, see [docs/json-descriptive.md](docs/json-descriptive.md) for full details .
23
- Labels are saved to the source file.
24
-
25
- ```json
26
- {
27
- "root": {
28
- "id": "root",
29
- "type": "root",
30
- "children": [
31
- {
32
- "id": "scene-1",
33
- "type": "folder",
34
- "children": [
35
- {
36
- "id": "scene-1-media",
37
- "type": "image",
38
- "src": "/media/photo.jpg",
39
- "start": 0, "end": 5,
40
- "description": "Best group shot from the campfire"
41
- }
42
- ]
43
- },
44
- {
45
- "id": "scene-2",
46
- "type": "folder",
47
- "children": [
48
- {
49
- "id": "scene-2-media",
50
- "type": "video",
51
- "src": "/media/clip.mp4",
52
- "start": 0, "end": 5,
53
- "description": "Sunset timelapse — use for intro"
54
- }
55
- ]
56
- }
57
- ]
58
- }
59
- }
60
- ```
61
-
62
- The `description` field on each media leaf node holds the label text.
63
-
64
- ## Workflow
65
-
66
- ```
67
- Stream tree JSON (labels.json)
68
-
69
-
70
- ─── preview --label ───→ Browse & label clips
71
- │ │
72
- │ ▼
73
- │ Updated labels.json
74
- │ │
75
- ▼ ▼
76
- Use in Storyboard ──────→ Assemble → Render
77
- ```
78
-
79
- 1. **Prepare** a stream tree JSON with scenes
80
- 2. **Label** with `preview --label` to add descriptions to each scene
81
- 3. **Export** labels are saved alongside the source file
82
- 4. **Use** the labeled scenes in your storyboard or assembly workflow
83
-
84
- ## API Endpoints
85
-
86
- | Endpoint | Method | Description |
87
- |----------|--------|-------------|
88
- | `/api/video-data` | GET | Returns the raw stream tree JSON |
89
- | `/api/video-info` | GET | Returns scenes array with timing + totalDuration |
90
- | `/api/labels` | GET | Returns the current stream tree with descriptions |
91
- | `/api/labels` | POST | Saves updated descriptions ({descriptions: {index: "text"}}) |
92
- | `/api/shutdown` | POST | Kills the server |
93
- | `/api/events` | GET | SSE events (reload on file change) |
94
-
95
- ## Implementation
96
-
97
- Source: `src/player/server.mjs` (unified server, use `--label` flag)
98
- UI controls: `src/player/components/LabelControls.tsx` (React component)
99
-
100
- - Built on Node.js `http` module (no Express dependency)
101
- - Serves bundled `player.js` (esbuild output from `src/player/browser.tsx`)
102
- - Labels saved atomically to disk on each POST
103
- - Supports stream tree JSON, descriptive JSON, and markdown input
104
- - React components in `src/player/components/` replace the old HTML-based `ui/` directory
105
-
106
- ## Reference
107
-
108
- | Topic | Link |
109
- |-------|------|
110
- | Storyboard (video planning) | [storyboard.md](storyboard.md) |
111
- | Stream tree types | `SKILL.md` |
112
- | Dynamic components | [dynamic-components.md](dynamic-components.md) |