@lalalic/markcut 1.2.0 → 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 (61) hide show
  1. package/AGENTS.md +39 -17
  2. package/README.md +7 -3
  3. package/SKILL.md +0 -2
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +34 -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/player/browser.tsx +178 -54
  16. package/src/player/bundle/player.js +1168 -566
  17. package/src/player/components/EditControls.tsx +92 -0
  18. package/src/player/components/HeaderBar.tsx +60 -0
  19. package/src/player/components/LabelControls.tsx +367 -0
  20. package/src/player/components/SceneThumbnails.tsx +87 -0
  21. package/src/player/components/VariantBar.tsx +39 -0
  22. package/src/player/components/index.ts +5 -0
  23. package/src/player/pipeline.mjs +130 -66
  24. package/src/player/pipeline.ts +3 -1
  25. package/src/player/server-shared.mjs +5 -7
  26. package/src/player/server.mjs +194 -187
  27. package/src/render/cli.mjs +4 -6
  28. package/src/schema/index.ts +20 -23
  29. package/src/types/Audio.tsx +25 -33
  30. package/src/types/Component.tsx +18 -24
  31. package/src/types/Effect.tsx +31 -39
  32. package/src/types/Image.tsx +23 -30
  33. package/src/types/Include.tsx +70 -76
  34. package/src/types/Map.tsx +48 -44
  35. package/src/types/Rhythm.tsx +19 -27
  36. package/src/types/Video.tsx +40 -47
  37. package/src/utils/index.ts +23 -10
  38. package/src/vision/cli.mjs +6 -6
  39. package/templates/courseware/TEMPLATE.md +16 -10
  40. package/tests/fixtures/audio.json +4 -2
  41. package/tests/fixtures/basic.json +2 -1
  42. package/tests/fixtures/component-all.json +4 -2
  43. package/tests/fixtures/components.json +4 -2
  44. package/tests/fixtures/effects.json +12 -6
  45. package/tests/fixtures/full.json +8 -4
  46. package/tests/fixtures/map.json +2 -1
  47. package/tests/fixtures/md/dialogue.md +12 -0
  48. package/tests/fixtures/md/edge-cases.md +9 -0
  49. package/tests/fixtures/scenes.json +4 -2
  50. package/tests/fixtures/subtitle.json +6 -3
  51. package/tests/fixtures/subvideo.json +11 -6
  52. package/tests/fixtures/templates/courseware.md +61 -4
  53. package/tests/fixtures/tween-visual.json +2 -6
  54. package/tests/fixtures/video-series.json +6 -14
  55. package/tests/md-descriptive.test.ts +170 -0
  56. package/tests/render.test.ts +32 -16
  57. package/tests/schema.test.ts +6 -3
  58. package/tests/server.test.ts +9 -6
  59. package/tests/utils.ts +4 -4
  60. package/scripts/artlist-dl.mjs +0 -190
  61. package/src/player/label-server.mjs +0 -599
@@ -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