@lalalic/markcut 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -5
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +5 -1
- package/skills/markcut/docs/markdown-descriptive.md +16 -7
- package/src/config.mjs +2 -1
- package/src/descriptive/compiler.ts +9 -5
- package/src/descriptive/markdown.ts +3 -1
- package/src/descriptive/resolve.ts +45 -17
- package/src/player/browser.tsx +32 -13
- package/src/player/bundle/player.js +93 -35
- 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/render/cli.mjs +67 -32
- package/src/schema/index.ts +0 -2
- package/src/types/Audio.tsx +1 -2
- package/src/types/Include.tsx +3 -11
- package/src/types/Subtitle.tsx +107 -12
- package/src/types/Video.tsx +1 -1
- package/tests/render.test.ts +8 -10
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) |
|
|
@@ -47,7 +47,7 @@ See [docs/markdown-strict-descriptive.md](docs/markdown-strict-descriptive.md) f
|
|
|
47
47
|
## CLI
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
markcut render <file.json|.md> [--
|
|
50
|
+
markcut render <file.json|.md> [--output path]
|
|
51
51
|
markcut preview <file.json|.md> [--edit] [--label] [--port 3001]
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -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[]` |
|
|
@@ -176,7 +176,7 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
|
|
|
176
176
|
### CLI Usage
|
|
177
177
|
|
|
178
178
|
```bash
|
|
179
|
-
node src/render/cli.mjs render <file.json> [--
|
|
179
|
+
node src/render/cli.mjs render <file.json> [--output path]
|
|
180
180
|
node src/render/cli.mjs render --template <id> --data <data.json>
|
|
181
181
|
node src/render/cli.mjs preview <file.json> [--edit] [--label] [--port 3001]
|
|
182
182
|
node src/render/cli.mjs templates
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Write a storyboard in markdown, get a rendered video with TTS narration and subt
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
# Render a storyboard to MP4
|
|
7
|
-
npx markcut render storyboard.md
|
|
7
|
+
npx markcut render storyboard.md
|
|
8
8
|
|
|
9
9
|
# Preview with live edit (edit .md file, player auto-reloads)
|
|
10
10
|
npx markcut preview storyboard.md --edit
|
package/package.json
CHANGED
package/skills/markcut/SKILL.md
CHANGED
|
@@ -83,4 +83,8 @@ some common issues (photo or video can't be displayed, audio missing), take belo
|
|
|
83
83
|
- audio cut off
|
|
84
84
|
- video cut off
|
|
85
85
|
- subtitle mismatch
|
|
86
|
-
- 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
|
|
89
|
+
- **don't** rm `.markcut` directory, which served as cache for all generated content. cache will auto update according to the content change. rm `.markcut` will cause all content to be regenerated, which is time consuming and wasteful.
|
|
90
|
+
- put all manual assets in `assets` folder, such as bgm, logo, watermark, etc. don't put them in `.markcut` folder, which is auto generated and will be deleted when `markcut clean` command is run.
|
|
@@ -6,7 +6,7 @@ Complete reference for LLM-driven video generation. The parser uses **remark** (
|
|
|
6
6
|
|
|
7
7
|
A markdown document compiled into a renderable scene tree.
|
|
8
8
|
|
|
9
|
-
- Top heading `#
|
|
9
|
+
- Top heading `# video` marks the document root. (Only `# video` is treated as the main section — `# anything_else` creates a **variant** section for alternate configs, see [Variants](#variants-language--platform--any-override).)
|
|
10
10
|
- Optional YAML frontmatter block `---\n...\n---\n` at the very top
|
|
11
11
|
- Root config line: `width:<n> height:<n> fps:<n> layout:<mode>` (key:value pairs on the line after `# video`)
|
|
12
12
|
- Scenes via `##`/`###`/`####` headings
|
|
@@ -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
|
+
~~~
|
package/src/config.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export const args=(function parseArgs(argv) {
|
|
|
20
20
|
"--tti": "tti",
|
|
21
21
|
"--ttv": "ttv"
|
|
22
22
|
};
|
|
23
|
-
const args = { command: "", file: "", output: "", forceNew: false, verbose: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
|
|
23
|
+
const args = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
|
|
24
24
|
let i = 2;
|
|
25
25
|
if (argv[i]) args.command = argv[i++];
|
|
26
26
|
if (argv[i] && !argv[i].startsWith("--")) args.file = argv[i++];
|
|
@@ -34,6 +34,7 @@ export const args=(function parseArgs(argv) {
|
|
|
34
34
|
else if (flag === "--compile") args.compile = true;
|
|
35
35
|
else if (flag === "--force-new") args.forceNew = true;
|
|
36
36
|
else if (flag === "--verbose") args.verbose = true;
|
|
37
|
+
else if (flag === "--dev") args.dev = true;
|
|
37
38
|
else if (flag === "--label") args.label = true;
|
|
38
39
|
else if (flag === "--edit") args.edit = true;
|
|
39
40
|
else if (flag === "--no-browser") args.noBrowser = true;
|
|
@@ -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;
|
|
@@ -412,11 +412,17 @@ export async function resolveScripts(
|
|
|
412
412
|
allScriptNodes.push({ node, id });
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
const totalScripts = allScriptNodes.length;
|
|
416
|
+
let scriptsDone = 0;
|
|
417
|
+
let cacheHits = 0;
|
|
418
|
+
const ttsStart = Date.now();
|
|
419
|
+
|
|
420
|
+
if (totalScripts > 0) {
|
|
421
|
+
console.log(` 🔊 TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
|
|
417
422
|
}
|
|
418
423
|
|
|
419
424
|
for (const { node, id } of allScriptNodes) {
|
|
425
|
+
scriptsDone++;
|
|
420
426
|
// TTS CLI from root config only
|
|
421
427
|
let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
422
428
|
|
|
@@ -440,13 +446,15 @@ export async function resolveScripts(
|
|
|
440
446
|
const label = `${speakerLabel}${firstWords(node.script, 8)}`;
|
|
441
447
|
if (cached) {
|
|
442
448
|
generated = cached;
|
|
449
|
+
cacheHits++;
|
|
443
450
|
console.log(` ✓ TTS: ${label} (cached)`);
|
|
444
451
|
} else {
|
|
452
|
+
console.log(` 🔊 TTS (${scriptsDone}/${totalScripts}): ${label}...`);
|
|
445
453
|
generated = generateTTS(node.script, audioPath, ttsCli);
|
|
446
454
|
if (generated) {
|
|
447
455
|
updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
|
|
448
456
|
cacheDirty = true;
|
|
449
|
-
console.log(` ✓ TTS: ${label}`);
|
|
457
|
+
console.log(` ✓ TTS (${scriptsDone}/${totalScripts}): ${label}`);
|
|
450
458
|
} else {
|
|
451
459
|
console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
|
|
452
460
|
}
|
|
@@ -459,10 +467,11 @@ export async function resolveScripts(
|
|
|
459
467
|
}
|
|
460
468
|
|
|
461
469
|
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
462
|
-
if (
|
|
470
|
+
if (totalScripts > 0) {
|
|
463
471
|
try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
|
|
472
|
+
const ttsElapsed = Math.round((Date.now() - ttsStart) / 1000);
|
|
464
473
|
const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
|
|
465
|
-
console.log(` ✅ TTS: ${unique} unique audio
|
|
474
|
+
console.log(` ✅ TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
|
|
466
475
|
}
|
|
467
476
|
return clone;
|
|
468
477
|
}
|
|
@@ -713,27 +722,38 @@ export async function resolveGeneratedMedia(
|
|
|
713
722
|
walkDown(clone as any, (node) => {
|
|
714
723
|
if ((node.type !== "image" && node.type !== "video")) return;
|
|
715
724
|
if (!node.prompt || typeof node.prompt !== "string") return;
|
|
716
|
-
// Skip if src is already set to a real path (prompt is just metadata).
|
|
717
|
-
// "auto" is a placeholder meaning "auto-generate via TTI/TTV".
|
|
718
725
|
if (node.src && node.src !== "auto") return;
|
|
719
726
|
const id = node.id ?? `${node.type}-${genNodes.length}`;
|
|
720
727
|
genNodes.push({ node, id, type: node.type as "image" | "video", prompt: node.prompt });
|
|
721
728
|
});
|
|
722
729
|
|
|
730
|
+
const total = genNodes.length;
|
|
731
|
+
let done = 0;
|
|
732
|
+
let cacheHits = 0;
|
|
733
|
+
const startTime = Date.now();
|
|
734
|
+
|
|
735
|
+
function progressLine() {
|
|
736
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
737
|
+
const rate = done / Math.max(elapsed, 0.1);
|
|
738
|
+
const remaining = total - done;
|
|
739
|
+
const eta = rate > 0 ? Math.round(remaining / rate) : "?";
|
|
740
|
+
const etaStr = eta === "?" ? "" : `, ~${eta}s remaining`;
|
|
741
|
+
return ` ${done}/${total} generated (${cacheHits} cached)${etaStr}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (total > 0) {
|
|
745
|
+
console.log(` 🎨 Generating ${total} media item${total > 1 ? "s" : ""}...`);
|
|
746
|
+
}
|
|
747
|
+
|
|
723
748
|
for (const { node, id, type, prompt } of genNodes) {
|
|
749
|
+
done++;
|
|
724
750
|
const ext = type === "image" ? "png" : "mp4";
|
|
725
751
|
|
|
726
|
-
// Resolve TTI/TTV config: root-level config overrides CLI defaults
|
|
727
752
|
const cli = type === "image"
|
|
728
753
|
? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
|
|
729
754
|
: (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
|
|
730
755
|
|
|
731
|
-
// Use root.seed for reproducible generation (applied when CLI template has {seed}).
|
|
732
|
-
// seed is part of the cache key so different seeds produce different cached outputs.
|
|
733
756
|
const seed = options.seed;
|
|
734
|
-
|
|
735
|
-
// Content-addressed filename: hash of prompt + CLI + type + seed, so identical
|
|
736
|
-
// prompts with different seeds produce distinct cached media.
|
|
737
757
|
const cacheKey = computeCacheKey({ prompt, cli, type, seed });
|
|
738
758
|
const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
|
|
739
759
|
|
|
@@ -743,12 +763,13 @@ export async function resolveGeneratedMedia(
|
|
|
743
763
|
|
|
744
764
|
if (cached) {
|
|
745
765
|
node.src = resolvePath(cached);
|
|
746
|
-
|
|
766
|
+
cacheHits++;
|
|
767
|
+
if (done % 5 === 0 || done === total) console.log(progressLine());
|
|
747
768
|
continue;
|
|
748
769
|
}
|
|
749
770
|
|
|
750
771
|
try {
|
|
751
|
-
console.log(` 🔊 ${label}: ${labelText}...`);
|
|
772
|
+
console.log(` 🔊 ${label} (${done}/${total}): ${labelText}...`);
|
|
752
773
|
const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
|
|
753
774
|
const result = type === "image"
|
|
754
775
|
? generateTTI(prompt, outputPath, cli, seed)
|
|
@@ -757,7 +778,8 @@ export async function resolveGeneratedMedia(
|
|
|
757
778
|
node.src = outputPath;
|
|
758
779
|
updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
|
|
759
780
|
cacheDirty = true;
|
|
760
|
-
console.log(` ✓ ${label}: ${labelText}`);
|
|
781
|
+
console.log(` ✓ ${label} (${done}/${total}): ${labelText}`);
|
|
782
|
+
if (done % 3 === 0 || done === total) console.log(progressLine());
|
|
761
783
|
} else {
|
|
762
784
|
const hint = cli.includes("echo")
|
|
763
785
|
? `No ${label} tool installed. The default CLI just echoes a message — set root.${type === "image" ? "tti" : "ttv"} to a real generation command.`
|
|
@@ -772,6 +794,11 @@ export async function resolveGeneratedMedia(
|
|
|
772
794
|
}
|
|
773
795
|
}
|
|
774
796
|
|
|
797
|
+
if (total > 0) {
|
|
798
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
799
|
+
const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
|
|
800
|
+
console.log(` ✅ ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
|
|
801
|
+
}
|
|
775
802
|
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
776
803
|
return clone;
|
|
777
804
|
}
|
|
@@ -986,7 +1013,8 @@ export async function resolveAll(
|
|
|
986
1013
|
|
|
987
1014
|
// Step 3: Generate images/videos from prompts before probing durations.
|
|
988
1015
|
// Use root.seed if set; otherwise inherit from options (for includes).
|
|
989
|
-
|
|
1016
|
+
// Auto-generate a random seed so {seed} in the CLI template always gets a value.
|
|
1017
|
+
const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2_147_483_647);
|
|
990
1018
|
if (options.mediaOutputDir) {
|
|
991
1019
|
result = await resolveGeneratedMedia(result, {
|
|
992
1020
|
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
|
/>
|