@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 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) |
@@ -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> [--aspect 16x9|9x16|1x1|all] [--output path]
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`, `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[]` |
@@ -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> [--aspect 16x9|9x16|1x1|all] [--output path]
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 --aspect 9x16
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -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 `# <name>` (heading text ignored, just marks document root; `# video`, `# sub-video`, `# anything` all work)
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 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
+ ~~~
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(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;
@@ -412,11 +412,17 @@ export async function resolveScripts(
412
412
  allScriptNodes.push({ node, id });
413
413
  });
414
414
 
415
- if (allScriptNodes.length > 0) {
416
- console.log(` 🔊 TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
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 (allScriptNodes.length > 0) {
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 file${unique > 1 ? "s" : ""} (${allScriptNodes.length} node${allScriptNodes.length > 1 ? "s" : ""})`);
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
- console.log(` ✓ ${label}: ${labelText} (cached)`);
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
- const generationSeed = result.seed ?? options.seed;
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,
@@ -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
  />