@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 +3 -3
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +14 -8
- package/skills/markcut/docs/markdown-descriptive.md +15 -6
- package/src/descriptive/compiler.ts +9 -5
- package/src/descriptive/markdown.ts +3 -1
- package/src/descriptive/resolve.ts +2 -1
- package/src/player/browser.tsx +32 -13
- package/src/player/bundle/player.js +92 -28
- package/src/player/components/SceneThumbnails.tsx +5 -2
- package/src/player/pipeline.mjs +12 -8
- package/src/player/pipeline.ts +4 -0
- package/src/player/server-shared.mjs +15 -6
- package/src/schema/index.ts +0 -2
- package/src/types/Audio.tsx +1 -2
- package/src/types/Subtitle.tsx +105 -10
- package/src/types/Video.tsx +1 -1
- package/skills/markcut/docs/edit-mode.md +0 -220
- package/skills/markcut/docs/label-mode.md +0 -112
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
|
|
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
|
|
138
|
-
| `audio` | Soundtrack/SFX | `src`, `volume`, `foreground` (ducks parent)
|
|
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
package/skills/markcut/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
762
|
+
- image|video isBackground:true
|
|
764
763
|
- audio|script
|
|
765
764
|
~~~
|
|
766
765
|
|
|
767
766
|
~~~ example - global streams
|
|
768
767
|
#video
|
|
769
|
-
- audio
|
|
770
|
-
- component
|
|
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
|
|
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(
|
|
1186
|
-
|
|
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:
|
|
1219
|
-
transitionTime:
|
|
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
|
-
|
|
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,
|
package/src/player/browser.tsx
CHANGED
|
@@ -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 (
|
|
145
|
-
if (currentTime >=
|
|
146
|
-
found =
|
|
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
|
|
222
|
+
// Throttle state updates lightly for scene tracking
|
|
219
223
|
setCurrentTime(prev => {
|
|
220
|
-
const newTime = frame /
|
|
221
|
-
return Math.abs(newTime - prev) > 0.
|
|
224
|
+
const newTime = frame / fps;
|
|
225
|
+
return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
|
|
222
226
|
});
|
|
223
|
-
}, [
|
|
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(
|
|
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
|
-
|
|
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 *
|
|
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 =
|
|
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 =
|
|
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
|
|
45901
|
-
const
|
|
45902
|
-
const
|
|
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)(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
60365
|
-
if (currentTime >= scenes[i3].start
|
|
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 (
|
|
60502
|
-
if (currentTime >=
|
|
60503
|
-
found =
|
|
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 /
|
|
60559
|
-
return Math.abs(newTime - prev) > 0.
|
|
60609
|
+
const newTime = frame / fps;
|
|
60610
|
+
return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
|
|
60560
60611
|
});
|
|
60561
|
-
}, [
|
|
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(
|
|
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
|
-
|
|
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 *
|
|
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 =
|
|
40
|
-
if (currentTime >= scenes[i].start
|
|
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
|
}
|
package/src/player/pipeline.mjs
CHANGED
|
@@ -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(
|
|
713
|
-
|
|
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:
|
|
740
|
-
transitionTime:
|
|
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, {
|
package/src/player/pipeline.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
end:
|
|
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
|
package/src/schema/index.ts
CHANGED
|
@@ -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>;
|
package/src/types/Audio.tsx
CHANGED
|
@@ -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 =
|
|
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
|
/>
|
package/src/types/Subtitle.tsx
CHANGED
|
@@ -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
|
|
110
|
-
const
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
{
|
|
301
|
+
{cueGroups.map((group, gi) => (
|
|
208
302
|
<CueFrame
|
|
209
|
-
key={
|
|
210
|
-
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}
|
package/src/types/Video.tsx
CHANGED
|
@@ -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 =
|
|
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) |
|