@lalalic/markcut 1.1.1 → 2.0.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.
Files changed (69) hide show
  1. package/AGENTS.md +39 -15
  2. package/README.md +7 -3
  3. package/SKILL.md +26 -172
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +46 -1
  7. package/docs/system-prompt-edit.md +16 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/entry.tsx +8 -2
  16. package/src/player/browser.tsx +178 -54
  17. package/src/player/bundle/player.js +1168 -566
  18. package/src/player/components/EditControls.tsx +92 -0
  19. package/src/player/components/HeaderBar.tsx +60 -0
  20. package/src/player/components/LabelControls.tsx +367 -0
  21. package/src/player/components/SceneThumbnails.tsx +87 -0
  22. package/src/player/components/VariantBar.tsx +39 -0
  23. package/src/player/components/index.ts +5 -0
  24. package/src/player/pipeline.mjs +130 -66
  25. package/src/player/pipeline.ts +3 -1
  26. package/src/player/server-shared.mjs +5 -7
  27. package/src/player/server.mjs +194 -187
  28. package/src/render/cli.mjs +66 -13
  29. package/src/schema/index.ts +20 -23
  30. package/src/types/Audio.tsx +25 -33
  31. package/src/types/Component.tsx +18 -24
  32. package/src/types/Effect.tsx +31 -39
  33. package/src/types/Image.tsx +23 -30
  34. package/src/types/Include.tsx +70 -76
  35. package/src/types/Map.tsx +48 -44
  36. package/src/types/Rhythm.tsx +19 -27
  37. package/src/types/Video.tsx +40 -47
  38. package/src/utils/component-import-map.ts +93 -0
  39. package/src/utils/index.ts +23 -10
  40. package/src/vision/cli.mjs +6 -6
  41. package/templates/courseware/TEMPLATE.md +317 -0
  42. package/templates/courseware/agents/reviewer.md +84 -0
  43. package/templates/courseware/prompts/fix.md +30 -0
  44. package/templates/courseware/prompts/outline.md +53 -0
  45. package/templates/courseware/prompts/scene.md +64 -0
  46. package/tests/fixtures/audio.json +4 -2
  47. package/tests/fixtures/basic.json +2 -1
  48. package/tests/fixtures/component-all.json +4 -2
  49. package/tests/fixtures/components.json +4 -2
  50. package/tests/fixtures/effects.json +12 -6
  51. package/tests/fixtures/full.json +8 -4
  52. package/tests/fixtures/map.json +2 -1
  53. package/tests/fixtures/md/dialogue.md +12 -0
  54. package/tests/fixtures/md/edge-cases.md +9 -0
  55. package/tests/fixtures/scenes.json +4 -2
  56. package/tests/fixtures/subtitle.json +6 -3
  57. package/tests/fixtures/subvideo.json +11 -6
  58. package/tests/fixtures/templates/courseware.md +61 -4
  59. package/tests/fixtures/tween-visual.json +2 -6
  60. package/tests/fixtures/video-series.json +6 -14
  61. package/tests/md-descriptive.test.ts +170 -0
  62. package/tests/render.test.ts +32 -16
  63. package/tests/schema.test.ts +6 -3
  64. package/tests/server.test.ts +9 -6
  65. package/tests/utils.ts +4 -4
  66. package/docs/dynamic-components.md +0 -191
  67. package/docs/templates.md +0 -52
  68. package/scripts/artlist-dl.mjs +0 -190
  69. package/src/player/label-server.mjs +0 -599
@@ -261,6 +261,117 @@ export async function resolveMediaDurations(
261
261
  return clone;
262
262
  }
263
263
 
264
+ // ── Dialogue expansion ──────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Detect if a script contains multi-turn dialogue in `SpeakerName: text` format.
268
+ *
269
+ * A script is a "dialogue" if at least 2 lines match the pattern `Name: text`.
270
+ * Single-line `SpeakerName: text` is NOT treated as dialogue — it's just a
271
+ * regular script with a speaker annotation.
272
+ *
273
+ * Example dialogue:
274
+ * ```
275
+ * Ray: Hello everyone and welcome
276
+ * Alice: Good day to you all
277
+ * Ray: Let's get started
278
+ * ```
279
+ *
280
+ * Each line becomes a separate audio node with:
281
+ * - `script`: just the text (without the "SpeakerName: " prefix)
282
+ * - `speaker`: the speaker name
283
+ *
284
+ * The dialogue lines are wrapped in a series container so they play sequentially
285
+ * regardless of the parent scene's layout.
286
+ */
287
+ export function parseDialogueLines(script: string): Array<{ speaker: string; text: string }> {
288
+ const lines = script.split("\n").map(l => l.trim()).filter(Boolean);
289
+ const dialoguePattern = /^([\w\u4e00-\u9fff][\w\s\u4e00-\u9fff]*?):\s*(.+)$/;
290
+
291
+ const result: Array<{ speaker: string; text: string }> = [];
292
+ for (const line of lines) {
293
+ const match = line.match(dialoguePattern);
294
+ if (!match) return []; // Any non-matching line invalidates the dialogue format
295
+ result.push({ speaker: match[1]!.trim(), text: match[2]!.trim() });
296
+ }
297
+
298
+ // Require at least 2 lines to be considered a dialogue
299
+ return result.length >= 2 ? result : [];
300
+ }
301
+
302
+ /**
303
+ * Walk the descriptive tree and expand multi-turn dialogue-formatted scripts
304
+ * into sequential per-line audio nodes.
305
+ *
306
+ * This runs BEFORE resolveScripts so each dialogue line gets its own TTS
307
+ * generation with the correct per-speaker voice.
308
+ */
309
+ export function resolveDialogue(root: DescriptiveRoot): DescriptiveRoot {
310
+ const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
311
+
312
+ // Collect dialogue nodes for expansion (walking and modifying simultaneously is tricky)
313
+ const expansions: Array<{
314
+ parent: any;
315
+ index: number;
316
+ originalNode: any;
317
+ lines: Array<{ speaker: string; text: string }>;
318
+ }> = [];
319
+
320
+ walkDown(clone as any, (node, parent) => {
321
+ if (node.type !== "audio") return;
322
+ if (!node.script || typeof node.script !== "string") return;
323
+ if (node.src) return; // Already has a real source
324
+
325
+ const lines = parseDialogueLines(node.script);
326
+ if (lines.length < 2) return; // Not a dialogue
327
+
328
+ if (!parent || !Array.isArray(parent.children)) return;
329
+ const idx = parent.children.indexOf(node);
330
+ if (idx === -1) return;
331
+
332
+ expansions.push({ parent, index: idx, originalNode: node, lines });
333
+ });
334
+
335
+ if (expansions.length === 0) return root;
336
+
337
+ // Apply expansions in reverse index order so splice indices stay valid
338
+ expansions.sort((a, b) => b.index - a.index);
339
+
340
+ for (const { parent, index, originalNode, lines } of expansions) {
341
+ const dialogueNodes: any[] = [];
342
+
343
+ for (let i = 0; i < lines.length; i++) {
344
+ const line = lines[i]!;
345
+ const { speaker, text } = line;
346
+ const lineNode: any = {
347
+ ...originalNode,
348
+ id: originalNode.id ? `${originalNode.id}-line-${i}` : undefined,
349
+ script: text,
350
+ speaker,
351
+ start: 0,
352
+ duration: undefined, // Will be set by TTS duration probing
353
+ };
354
+ delete lineNode.src;
355
+ dialogueNodes.push(lineNode);
356
+ }
357
+
358
+ // Wrap dialogue lines in a series container for sequential playback
359
+ const seriesContainer: any = {
360
+ type: "series",
361
+ id: `${originalNode.id ?? "dialogue"}-series`,
362
+ children: dialogueNodes,
363
+ };
364
+
365
+ // Replace the original node with the series container
366
+ parent.children.splice(index, 1, seriesContainer);
367
+ }
368
+
369
+ const expanded = expansions.length > 0 ? clone : root;
370
+ const count = expansions.reduce((sum, e) => sum + e.lines.length, 0);
371
+ console.log(` 💬 dialogue: expanded ${expansions.length} script${expansions.length > 1 ? "s" : ""} into ${count} lines`);
372
+ return expanded;
373
+ }
374
+
264
375
  // ── Script → TTS → STT ─────────────────────────────────────────────────────
265
376
 
266
377
  export interface ResolveScriptOptions {
@@ -292,23 +403,30 @@ export async function resolveScripts(
292
403
  let cacheDirty = false;
293
404
 
294
405
  // Collect all audio nodes that have script text but no src yet
295
- // Also store their parent scene for per-scene tts resolution
296
- const allScriptNodes: Array<{ node: any; id: string; parent: any }> = [];
297
- walkDown(clone as any, (node, parent) => {
406
+ const allScriptNodes: Array<{ node: any; id: string }> = [];
407
+ walkDown(clone as any, (node) => {
298
408
  if (node.type !== "audio") return;
299
409
  if (!node.script || typeof node.script !== "string") return;
300
410
  if (node.src) return; // already has real source
301
411
  const id = node.id ?? `audio-${allScriptNodes.length}`;
302
- allScriptNodes.push({ node, id, parent });
412
+ allScriptNodes.push({ node, id });
303
413
  });
304
414
 
305
415
  if (allScriptNodes.length > 0) {
306
416
  console.log(` 🔊 TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
307
417
  }
308
418
 
309
- for (const { node, id, parent } of allScriptNodes) {
310
- // Resolve TTS config: parent scene tts > root tts > options ttsCli > default
311
- const ttsCli = parent?.tts ?? clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
419
+ for (const { node, id } of allScriptNodes) {
420
+ // TTS CLI from root config only
421
+ let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
422
+
423
+ // Per-speaker voice appends extra CLI flags from root voices config
424
+ if (node.speaker && clone.voices) {
425
+ const speakerVoice = clone.voices[node.speaker];
426
+ if (speakerVoice) {
427
+ ttsCli = `${ttsCli} ${speakerVoice}`;
428
+ }
429
+ }
312
430
 
313
431
  // Content-addressed filename: hash of script + CLI, so identical scripts
314
432
  // across different source files share the same audio file.
@@ -318,7 +436,8 @@ export async function resolveScripts(
318
436
  // Check cache — skip TTS if script + config unchanged AND audio file exists
319
437
  const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
320
438
  let generated: string;
321
- const label = firstWords(node.script, 8);
439
+ const speakerLabel = node.speaker ? `${node.speaker}: ` : "";
440
+ const label = `${speakerLabel}${firstWords(node.script, 8)}`;
322
441
  if (cached) {
323
442
  generated = cached;
324
443
  console.log(` ✓ TTS: ${label} (cached)`);
@@ -378,15 +497,16 @@ export async function resolveSubtitles(
378
497
  let cacheDirty = false;
379
498
 
380
499
  // Collect { audioSrc, absoluteOffset } from the compiled tree
381
- const clips: Array<{ audioSrc: string; offset: number }> = [];
500
+ const clips: Array<{ audioSrc: string; offset: number; speaker?: string }> = [];
382
501
 
383
502
  /**
384
503
  * Walk an array of sibling nodes, tracking cumulative offset for series layouts.
385
504
  *
386
- * In the compiled stream tree, container nodes (Folder/Scene) don't carry `actions`
387
- * — their position on the timeline is implicit in the `<Series>`/`<TransitionSeries>`
388
- * renderer which plays each child sequentially based on `durationInSeconds`.
389
- * This function replicates that logic to compute absolute audio offsets.
505
+ * In the compiled stream tree, container nodes (Folder/Scene) don't carry leaf
506
+ * timing (start/end) — their position on the timeline is implicit in the
507
+ * `<Series>`/`<TransitionSeries>` renderer which plays each child sequentially
508
+ * based on `durationInSeconds`. This function replicates that logic to compute
509
+ * absolute audio offsets.
390
510
  */
391
511
  function walkSiblings(
392
512
  nodes: any[],
@@ -404,13 +524,13 @@ export async function resolveSubtitles(
404
524
  // In a series, each child starts after all previous siblings' durations.
405
525
  // In parallel, all children share the parent offset.
406
526
  const nodeStart = parentIsSeries ? seriesOffset : parentOffset;
407
- // Leaf nodes (video/audio/image/component) carry an action start offset
408
- // relative to the container start (e.g., parallel layout with staggered start).
409
- const actionStart = node.actions?.[0]?.start ?? 0;
527
+ // Leaf nodes carry a start offset relative to the container start
528
+ // (e.g., parallel layout with staggered start) directly on the base field.
529
+ const actionStart = node.start ?? 0;
410
530
  const effectiveOffset = nodeStart + actionStart;
411
531
 
412
532
  if (node.type === "audio" && node.src) {
413
- clips.push({ audioSrc: node.src, offset: effectiveOffset });
533
+ clips.push({ audioSrc: node.src, offset: effectiveOffset, speaker: node.speaker });
414
534
  }
415
535
 
416
536
  // Recurse into children — determine their layout from the node type
@@ -449,7 +569,7 @@ export async function resolveSubtitles(
449
569
  }
450
570
  }
451
571
 
452
- // Walk the compiled tree (with actions) for correct absolute offsets;
572
+ // Walk the compiled tree (with base timing) for correct absolute offsets;
453
573
  // fall back to the descriptive tree if no compiled tree provided.
454
574
  const treeToWalk = options.compiled ?? (clone as any);
455
575
  walkSiblings(
@@ -466,7 +586,7 @@ export async function resolveSubtitles(
466
586
 
467
587
  let sttFailedCount = 0;
468
588
 
469
- for (const { audioSrc, offset } of clips) {
589
+ for (const { audioSrc, offset, speaker } of clips) {
470
590
  // Cache key: audio hash + STT CLI string
471
591
  const audioHash = existsSync(audioSrc)
472
592
  ? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
@@ -518,7 +638,11 @@ export async function resolveSubtitles(
518
638
  const sec = (s % 60).toFixed(3).padStart(6, "0");
519
639
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${sec}`;
520
640
  };
521
- const text = lines.slice(lines.indexOf(tline) + 1).join("\n").trim();
641
+ let text = lines.slice(lines.indexOf(tline) + 1).join("\n").trim();
642
+ // Prepend speaker prefix for dialogue cues
643
+ if (speaker) {
644
+ text = `${speaker}: ${text}`;
645
+ }
522
646
  mergedLines.push(String(cueIndex++));
523
647
  mergedLines.push(`${formatSec(toSec(a) + offset)} --> ${formatSec(toSec(z) + offset)}`);
524
648
  mergedLines.push(text, "");
@@ -864,6 +988,10 @@ export async function resolveAll(
864
988
  skip: options.skip,
865
989
  });
866
990
 
991
+ // Step 4.5: Expand multi-turn dialogue (SpeakerName: text format)
992
+ // Must run before TTS so each dialogue line gets its own audio node
993
+ result = resolveDialogue(result);
994
+
867
995
  // Step 5: Script → TTS
868
996
  if (options.scriptOutputDir) {
869
997
  result = await resolveScripts(result, {
package/src/entry.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
- import { AbsoluteFill, continueRender, delayRender } from "remotion";
2
+ import { AbsoluteFill, continueRender, delayRender, staticFile } from "remotion";
3
+ import { ensureSharedImportMap } from "./utils/component-import-map";
3
4
  import { ComposeContext, EventProvider, type ComposeContextValue } from "./context/index";
4
5
 
5
6
  import { FolderLeaf } from "./types/Folder";
@@ -58,7 +59,12 @@ function useComponentRegistry(imports: unknown): Record<string, React.ComponentT
58
59
  if (!handleRef.current) {
59
60
  handleRef.current = delayRender("Loading component registry: " + imports);
60
61
  }
61
- import(/* webpackIgnore: true */ imports)
62
+ // Absolute URLs and "/"-rooted paths (preview server) load as-is;
63
+ // relative paths (render CLI stages bundles into publicDir) resolve
64
+ // through staticFile so the Remotion render server can serve them.
65
+ const moduleUrl = /^(https?:|data:|blob:|\/)/.test(imports) ? imports : staticFile(imports);
66
+ ensureSharedImportMap();
67
+ import(/* webpackIgnore: true */ moduleUrl)
62
68
  .then((mod: any) => {
63
69
  // The bundle exports all components as named exports
64
70
  setRegistry(mod.default ?? mod);
@@ -2,6 +2,11 @@
2
2
  * Browser player entry point.
3
3
  * Bundled with esbuild and served by the player server.
4
4
  * Renders stream tree JSON using @remotion/player with MarkCut.
5
+ *
6
+ * Supports three modes passed via window.MODE:
7
+ * "label" — label annotation overlay
8
+ * "edit" — edit input with AI auto-reload
9
+ * (default) — plain preview with optional variant bar
5
10
  */
6
11
  import * as React from "react";
7
12
  import { createRoot } from "react-dom/client";
@@ -9,6 +14,7 @@ import * as ReactDOM from "react-dom";
9
14
  import * as Remotion from "remotion";
10
15
  import { Player } from "@remotion/player";
11
16
  import { MarkCut, getDurationInSeconds } from "../entry";
17
+ import { HeaderBar, EditControls, LabelControls, SceneThumbnails, VariantBar } from "./components/index";
12
18
 
13
19
  /**
14
20
  * Register all player-bundled packages on a global registry so the import map
@@ -106,25 +112,58 @@ function PlayerApp() {
106
112
  const [ready, setReady] = React.useState(false);
107
113
  const [error, setError] = React.useState<string | null>(null);
108
114
  const [data, setData] = React.useState<any>(null);
109
- const [refreshKey, setRefreshKey] = React.useState(0);
110
115
  const [muted, setMuted] = React.useState(false);
111
116
  const [volume, setVolume] = React.useState(1);
112
- const seekAttemptedRef = React.useRef(false);
113
117
  const mountedRef = React.useRef(true);
114
118
 
119
+ // ── Track current player frame without re-renders ───────────────────
120
+ const currentFrameRef = React.useRef(0);
121
+ const [currentTime, setCurrentTime] = React.useState(0);
122
+ const [activeScene, setActiveScene] = React.useState("");
123
+
124
+ // Fetch scenes for active scene tracking
125
+ React.useEffect(() => {
126
+ fetch("/api/video-info")
127
+ .then((r) => r.json())
128
+ .then((info) => {
129
+ if (info.scenes) {
130
+ // Store scenes globally for time-based lookup
131
+ (window as any).__scenes = info.scenes;
132
+ }
133
+ })
134
+ .catch(() => {});
135
+ }, []);
136
+
137
+ // Update active scene from currentTime
138
+ React.useEffect(() => {
139
+ const scenes = (window as any).__scenes;
140
+ if (!scenes) return;
141
+ let found = "";
142
+ for (const s of scenes) {
143
+ if (currentTime >= s.start && currentTime < s.end) {
144
+ found = s.name || "";
145
+ break;
146
+ }
147
+ }
148
+ setActiveScene(found);
149
+ }, [currentTime]);
150
+
151
+ // ── Save/restore position across reloads ────────────────────────────
152
+ const pendingSeekRef = React.useRef<number | null>(null);
153
+
115
154
  // Parse URL params
116
155
  const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "");
117
156
  const autoPlay = urlParams.get("autoplay") === "true";
118
157
  const startAt = parseFloat(urlParams.get("start") || urlParams.get("t") || "0") || 0;
119
158
 
120
159
  // Derive player config from data early so effects can reference them safely.
121
- // These are computed on every render but only meaningful when data is set.
122
160
  const fps = data?.fps ?? 30;
123
161
  const durationInSeconds = data ? (getDurationInSeconds(data, true) || 5) : 5;
124
162
  const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
125
163
 
126
- const loadData = React.useCallback(() => {
127
- setReady(false);
164
+ // ── Load data (does NOT set ready=false to avoid player unmount) ─────
165
+ const loadData = React.useCallback((initial = false) => {
166
+ if (initial) setReady(false);
128
167
  const variant = (window as any).VARIANT || "default";
129
168
  const url = variant !== "default" ? `/api/video-data?variant=${variant}` : "/api/video-data";
130
169
  fetch(url)
@@ -132,38 +171,55 @@ function PlayerApp() {
132
171
  .then((json) => {
133
172
  const root = json.root || json;
134
173
  setData(root);
135
- setReady(true);
174
+ if (initial) setReady(true);
136
175
  })
137
176
  .catch((e) => setError(e.message));
138
177
  }, []);
139
178
 
179
+ // ── Initial load ────────────────────────────────────────────────────
140
180
  React.useEffect(() => {
141
- loadData();
142
- const handler = () => { setRefreshKey(k => k + 1); };
143
- window.addEventListener("refresh-player", handler);
144
- return () => { mountedRef.current = false; window.removeEventListener("refresh-player", handler); };
181
+ loadData(true);
182
+ return () => { mountedRef.current = false; };
145
183
  }, [loadData]);
146
184
 
185
+ // ── SSE reload: save position, load new data, restore position ──────
147
186
  React.useEffect(() => {
148
- if (refreshKey > 0) loadData();
149
- }, [refreshKey, loadData]);
187
+ const handler = () => {
188
+ // Save current position before reload
189
+ if (playerRef.current) {
190
+ pendingSeekRef.current = playerRef.current.getCurrentFrame();
191
+ }
192
+ loadData(false);
193
+ };
194
+ window.addEventListener("refresh-player", handler);
195
+ return () => window.removeEventListener("refresh-player", handler);
196
+ }, [loadData]);
150
197
 
151
- // Seek to startAt once player is mounted
198
+ // ── After data loads, seek to saved position or startAt ─────────────
152
199
  React.useEffect(() => {
153
- if (!ready || !data || seekAttemptedRef.current) return;
154
- if (startAt > 0 && playerRef.current) {
155
- // Small delay to let the player fully initialize
200
+ if (!ready || !data || !playerRef.current) return;
201
+ const targetFrame = pendingSeekRef.current ?? Math.round(startAt * fps);
202
+ if (targetFrame > 0) {
156
203
  const timer = setTimeout(() => {
157
204
  if (!mountedRef.current || !playerRef.current) return;
158
- const frame = Math.round(startAt * fps);
159
- playerRef.current.seekTo(frame);
160
- seekAttemptedRef.current = true;
205
+ playerRef.current.seekTo(targetFrame);
206
+ pendingSeekRef.current = null;
161
207
  }, 100);
162
208
  return () => clearTimeout(timer);
163
209
  }
164
- seekAttemptedRef.current = true;
210
+ pendingSeekRef.current = null;
165
211
  }, [ready, data, startAt, fps]);
166
212
 
213
+ // ── onFrameUpdate: track current time ───────────────────────────────
214
+ const handleFrameUpdate = React.useCallback((frame: number) => {
215
+ currentFrameRef.current = frame;
216
+ // Throttle state updates to ~2fps for scene tracking
217
+ setCurrentTime(prev => {
218
+ const newTime = frame / (data?.fps ?? 30);
219
+ return Math.abs(newTime - prev) > 0.5 ? newTime : prev;
220
+ });
221
+ }, [data?.fps]);
222
+
167
223
  // Keyboard shortcuts
168
224
  React.useEffect(() => {
169
225
  if (!ready || !playerRef.current) return;
@@ -300,52 +356,120 @@ function PlayerApp() {
300
356
  return () => window.removeEventListener("keydown", onKey);
301
357
  }, [ready, data, fps, durationInFrames, volume]);
302
358
 
303
- // Expose seek API for external scripts
359
+ // Determine mode from global (set via HTML by the server)
360
+ const mode: string =
361
+ (typeof window !== "undefined" ? (window as any).MODE : null) || "preview";
362
+
363
+ // Shared state for header info
364
+ const [editStatus, setEditStatus] = React.useState("");
365
+ const [sseConnected, setSseConnected] = React.useState(false);
366
+ const [labelSceneInfo, setLabelSceneInfo] = React.useState("");
367
+
368
+ // SSE connection — shared across all modes as a server-liveness monitor
369
+ // Edit mode also listens for "reload" messages (auto-refresh on file change)
370
+ const suppressReloadRef = React.useRef(false);
304
371
  React.useEffect(() => {
305
- if (!data) return;
306
- (window as any).__remotionSeekTo = (timeInSeconds: number) => {
307
- const frame = Math.round(timeInSeconds * fps);
308
- playerRef.current?.seekTo(frame);
372
+ let evtSource: EventSource | null = null;
373
+ try {
374
+ evtSource = new EventSource("/api/events");
375
+ evtSource.onopen = () => setSseConnected(true);
376
+ evtSource.onmessage = (e: MessageEvent) => {
377
+ try {
378
+ const msg = JSON.parse(e.data);
379
+ if (msg.type === "reload" && !suppressReloadRef.current) {
380
+ window.dispatchEvent(new Event("refresh-player"));
381
+ }
382
+ } catch {}
383
+ };
384
+ evtSource.onerror = () => setSseConnected(false);
385
+ } catch {
386
+ setSseConnected(false);
387
+ }
388
+ return () => {
389
+ evtSource?.close();
390
+ setSseConnected(false);
309
391
  };
310
- });
392
+ }, []);
311
393
 
312
394
  if (error) {
313
- return React.createElement("div", {
314
- style: { color: "red", padding: 40, fontFamily: "sans-serif" },
315
- }, "Error: " + error);
395
+ return <div style={{ color: "red", padding: 40, fontFamily: "sans-serif" }}>Error: {error}</div>;
316
396
  }
317
397
 
318
398
  if (!ready) {
319
- return React.createElement("div", {
320
- style: { color: "#888", padding: 40, fontFamily: "sans-serif" },
321
- }, "Loading...");
399
+ return <div style={{ color: "#888", padding: 40, fontFamily: "sans-serif" }}>Loading...</div>;
322
400
  }
323
401
 
324
402
  const width = data.width || 1080;
325
403
  const height = data.height || 1920;
326
404
 
327
- return React.createElement("div", {
328
- style: { width: "100%", height: "100%", background: "#000" },
329
- },
330
- React.createElement(Player, {
331
- ref: playerRef,
332
- component: MarkCut,
333
- inputProps: {
334
- root: data,
335
- compose: {},
336
- },
337
- durationInFrames,
338
- fps,
339
- compositionWidth: width,
340
- compositionHeight: height,
341
- style: { width: "100%", height: "100%" },
342
- controls: true,
343
- showPlaybackRateControl: true,
344
- allowFullscreen: true,
345
- clickToPlay: false,
346
- doubleClickToFullscreen: true,
347
- autoPlay: autoPlay,
348
- })
405
+ return (
406
+ <div
407
+ style={{
408
+ width: "100%", height: "100%", background: "#0a0a0a",
409
+ display: "flex", flexDirection: "column", alignItems: "center",
410
+ }}
411
+ >
412
+ {/* ── Header (close button + mode info) ── */}
413
+ <HeaderBar
414
+ mode={mode}
415
+ editStatus={editStatus}
416
+ sseConnected={sseConnected}
417
+ sceneInfo={mode === "label" ? labelSceneInfo : undefined}
418
+ />
419
+
420
+ {/* ── Variant switcher ── */}
421
+ <VariantBar />
422
+
423
+ {/* ── Player frame ── */}
424
+ <div id="player-frame" style={{ flex: 1, width: "100%", maxWidth: 480, minHeight: 0 }}>
425
+ <Player
426
+ ref={playerRef}
427
+ component={MarkCut}
428
+ inputProps={{ root: data, compose: {} }}
429
+ durationInFrames={durationInFrames}
430
+ fps={fps}
431
+ compositionWidth={width}
432
+ compositionHeight={height}
433
+ style={{ width: "100%", height: "100%" }}
434
+ controls={true}
435
+ showPlaybackRateControl={true}
436
+ allowFullscreen={true}
437
+ clickToPlay={false}
438
+ doubleClickToFullscreen={true}
439
+ autoPlay={autoPlay}
440
+ onFrameUpdate={handleFrameUpdate}
441
+ />
442
+ </div>
443
+
444
+ {/* ── Scene thumbnails (shared across all modes) ── */}
445
+ <SceneThumbnails
446
+ currentTime={currentTime}
447
+ onSeek={(t) => {
448
+ if (playerRef.current) {
449
+ const frame = Math.round(t * (data?.fps ?? 30));
450
+ playerRef.current.seekTo(frame);
451
+ }
452
+ }}
453
+ />
454
+
455
+ {/* ── Mode-specific controls ── */}
456
+ {mode === "edit" && (
457
+ <EditControls
458
+ onStatusChange={setEditStatus}
459
+ suppressReloadRef={suppressReloadRef}
460
+ currentTime={currentTime}
461
+ activeScene={activeScene}
462
+ />
463
+ )}
464
+ {mode === "label" && (
465
+ <LabelControls
466
+ playerRef={playerRef}
467
+ currentTime={currentTime}
468
+ onSceneChange={setLabelSceneInfo}
469
+ />
470
+ )}
471
+ {/* Preview mode: no extra controls */}
472
+ </div>
349
473
  );
350
474
  }
351
475