@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.
- package/AGENTS.md +39 -17
- package/README.md +7 -3
- package/SKILL.md +0 -2
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +34 -1
- package/docs/system-prompt-edit.md +16 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -3
- package/src/descriptive/compiler.test.ts +42 -42
- package/src/descriptive/compiler.ts +41 -52
- package/src/descriptive/markdown.ts +8 -4
- package/src/descriptive/resolve.test.ts +14 -7
- package/src/descriptive/resolve.ts +148 -20
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1168 -566
- package/src/player/components/EditControls.tsx +92 -0
- package/src/player/components/HeaderBar.tsx +60 -0
- package/src/player/components/LabelControls.tsx +367 -0
- package/src/player/components/SceneThumbnails.tsx +87 -0
- package/src/player/components/VariantBar.tsx +39 -0
- package/src/player/components/index.ts +5 -0
- package/src/player/pipeline.mjs +130 -66
- package/src/player/pipeline.ts +3 -1
- package/src/player/server-shared.mjs +5 -7
- package/src/player/server.mjs +194 -187
- package/src/render/cli.mjs +4 -6
- package/src/schema/index.ts +20 -23
- package/src/types/Audio.tsx +25 -33
- package/src/types/Component.tsx +18 -24
- package/src/types/Effect.tsx +31 -39
- package/src/types/Image.tsx +23 -30
- package/src/types/Include.tsx +70 -76
- package/src/types/Map.tsx +48 -44
- package/src/types/Rhythm.tsx +19 -27
- package/src/types/Video.tsx +40 -47
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +16 -10
- package/tests/fixtures/audio.json +4 -2
- package/tests/fixtures/basic.json +2 -1
- package/tests/fixtures/component-all.json +4 -2
- package/tests/fixtures/components.json +4 -2
- package/tests/fixtures/effects.json +12 -6
- package/tests/fixtures/full.json +8 -4
- package/tests/fixtures/map.json +2 -1
- package/tests/fixtures/md/dialogue.md +12 -0
- package/tests/fixtures/md/edge-cases.md +9 -0
- package/tests/fixtures/scenes.json +4 -2
- package/tests/fixtures/subtitle.json +6 -3
- package/tests/fixtures/subvideo.json +11 -6
- package/tests/fixtures/templates/courseware.md +61 -4
- package/tests/fixtures/tween-visual.json +2 -6
- package/tests/fixtures/video-series.json +6 -14
- package/tests/md-descriptive.test.ts +170 -0
- package/tests/render.test.ts +32 -16
- package/tests/schema.test.ts +6 -3
- package/tests/server.test.ts +9 -6
- package/tests/utils.ts +4 -4
- package/scripts/artlist-dl.mjs +0 -190
- package/src/player/label-server.mjs +0 -599
package/src/player/browser.tsx
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
198
|
+
// ── After data loads, seek to saved position or startAt ─────────────
|
|
152
199
|
React.useEffect(() => {
|
|
153
|
-
if (!ready || !data ||
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
seekAttemptedRef.current = true;
|
|
205
|
+
playerRef.current.seekTo(targetFrame);
|
|
206
|
+
pendingSeekRef.current = null;
|
|
161
207
|
}, 100);
|
|
162
208
|
return () => clearTimeout(timer);
|
|
163
209
|
}
|
|
164
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|