@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EditControls — edit mode controls for the bottom bar.
|
|
3
|
+
*
|
|
4
|
+
* Renders the edit input + submit button.
|
|
5
|
+
* SSE connection is managed by the app shell (browser.tsx).
|
|
6
|
+
* This component toggles suppressReloadRef during edit to prevent
|
|
7
|
+
* auto-reload from racing with the AI edit output.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
interface EditControlsProps {
|
|
12
|
+
/** Called whenever the edit status changes (for HeaderBar) */
|
|
13
|
+
onStatusChange?: (status: string) => void;
|
|
14
|
+
/** Ref shared with app shell — set true during edit to suppress SSE-triggered reload */
|
|
15
|
+
suppressReloadRef?: React.MutableRefObject<boolean>;
|
|
16
|
+
/** Current player time in seconds (sent with edit request for context) */
|
|
17
|
+
currentTime?: number;
|
|
18
|
+
/** Active scene name (sent with edit request for context) */
|
|
19
|
+
activeScene?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function EditControls({ onStatusChange, suppressReloadRef, currentTime, activeScene }: EditControlsProps) {
|
|
23
|
+
const [busy, setBusy] = React.useState(false);
|
|
24
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
25
|
+
|
|
26
|
+
// ── Edit submit ──────────────────────────────────────────────────────
|
|
27
|
+
const handleApplyEdit = React.useCallback(
|
|
28
|
+
async (text: string) => {
|
|
29
|
+
if (!text || busy) return;
|
|
30
|
+
setBusy(true);
|
|
31
|
+
onStatusChange?.("\u231B editing...");
|
|
32
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
33
|
+
if (suppressReloadRef) suppressReloadRef.current = true;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch("/api/edit", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ text, currentTime, activeScene }),
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
if (res.ok) {
|
|
43
|
+
const summary = (data.output || "done").split("\n")[0].slice(0, 65);
|
|
44
|
+
onStatusChange?.(summary);
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
if (suppressReloadRef) suppressReloadRef.current = false;
|
|
47
|
+
window.dispatchEvent(new Event("refresh-player"));
|
|
48
|
+
}, 4000);
|
|
49
|
+
} else {
|
|
50
|
+
onStatusChange?.("\u274C " + (data.error || "failed"));
|
|
51
|
+
if (suppressReloadRef) suppressReloadRef.current = false;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
onStatusChange?.("\u274C error");
|
|
55
|
+
if (suppressReloadRef) suppressReloadRef.current = false;
|
|
56
|
+
}
|
|
57
|
+
setBusy(false);
|
|
58
|
+
},
|
|
59
|
+
[busy, onStatusChange, suppressReloadRef]
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const handleKeyDown = React.useCallback(
|
|
63
|
+
(e: React.KeyboardEvent) => {
|
|
64
|
+
if (e.key === "Enter") {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
handleApplyEdit((e.target as HTMLInputElement).value);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[handleApplyEdit]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ── Render ───────────────────────────────────────────────────────────
|
|
73
|
+
return (
|
|
74
|
+
<div id="bottom-bar">
|
|
75
|
+
<input
|
|
76
|
+
ref={inputRef}
|
|
77
|
+
id="edit-input"
|
|
78
|
+
placeholder="What should change? e.g. make text bigger"
|
|
79
|
+
onKeyDown={handleKeyDown}
|
|
80
|
+
/>
|
|
81
|
+
<button
|
|
82
|
+
id="edit-btn"
|
|
83
|
+
title="Apply edit"
|
|
84
|
+
disabled={busy}
|
|
85
|
+
onClick={() => handleApplyEdit(inputRef.current?.value || "")}
|
|
86
|
+
>
|
|
87
|
+
✨
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeaderBar — shared header for all modes.
|
|
3
|
+
*
|
|
4
|
+
* Always renders the close button (→ /api/shutdown).
|
|
5
|
+
* Edit mode: shows SSE connection status + edit result feedback.
|
|
6
|
+
* Label mode: shows current scene name + time.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
|
|
10
|
+
interface HeaderBarProps {
|
|
11
|
+
/** "preview" | "edit" | "label" */
|
|
12
|
+
mode: string;
|
|
13
|
+
/** Label mode scene info text (e.g. "slide1 (1.2s)") */
|
|
14
|
+
sceneInfo?: string;
|
|
15
|
+
/** Edit mode status text (e.g. "✅ done", "⏳ editing...") */
|
|
16
|
+
editStatus?: string;
|
|
17
|
+
/** Edit mode: whether SSE is connected */
|
|
18
|
+
sseConnected?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function HeaderBar({ mode, sceneInfo, editStatus, sseConnected }: HeaderBarProps) {
|
|
22
|
+
const handleClose = React.useCallback(() => {
|
|
23
|
+
navigator.sendBeacon("/api/shutdown", "{}");
|
|
24
|
+
document.body.innerHTML =
|
|
25
|
+
"<div style='display:flex;align-items:center;justify-content:center;height:100vh;background:#0a0a0a;color:#555;font-family:sans-serif;font-size:16px'>\u2B61 player closed \u2014 return to terminal</div>";
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div id="header">
|
|
30
|
+
{/* Left: mode-specific info */}
|
|
31
|
+
<span style={{ flex: 1, display: "flex", alignItems: "center", gap: 8 }}>
|
|
32
|
+
{mode === "label" && sceneInfo && (
|
|
33
|
+
<span id="scene-info">{sceneInfo}</span>
|
|
34
|
+
)}
|
|
35
|
+
{mode === "edit" && editStatus && (
|
|
36
|
+
<span id="edit-status">{editStatus}</span>
|
|
37
|
+
)}
|
|
38
|
+
{/* SSE indicator — shown in all modes */}
|
|
39
|
+
<span
|
|
40
|
+
id="sse-indicator"
|
|
41
|
+
title={sseConnected ? "Connected — auto-reload ready" : "Disconnected"}
|
|
42
|
+
style={{
|
|
43
|
+
display: "inline-block",
|
|
44
|
+
width: 8,
|
|
45
|
+
height: 8,
|
|
46
|
+
borderRadius: "50%",
|
|
47
|
+
background: sseConnected ? "#4ade80" : "#555",
|
|
48
|
+
flexShrink: 0,
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
</span>
|
|
52
|
+
{/* Right: close button */}
|
|
53
|
+
<div id="header-actions">
|
|
54
|
+
<button id="close-btn" title="Close player and return to terminal" onClick={handleClose}>
|
|
55
|
+
✕
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LabelControls — label mode controls.
|
|
3
|
+
*
|
|
4
|
+
* Renders:
|
|
5
|
+
* - Thumbnail strip with label badges (between header and player)
|
|
6
|
+
* - Timed labels list with delete (below player)
|
|
7
|
+
* - Bottom input bar with save button
|
|
8
|
+
*
|
|
9
|
+
* Scene/time info is pushed up via onSceneChange for HeaderBar.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
14
|
+
interface Scene {
|
|
15
|
+
name: string;
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
duration: number;
|
|
19
|
+
src: string;
|
|
20
|
+
mediaType: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LabelEntry {
|
|
24
|
+
overall: string;
|
|
25
|
+
timed: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface VideoInfo {
|
|
29
|
+
scenes: Scene[];
|
|
30
|
+
totalDuration: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
34
|
+
const VIDEO_EXT: Record<string, number> = {
|
|
35
|
+
".mov": 1, ".mp4": 1, ".avi": 1, ".mkv": 1, ".webm": 1, ".m4v": 1, ".wmv": 1,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function escHtml(s: string): string {
|
|
39
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatTime(sec: number): string {
|
|
43
|
+
const mm = Math.floor(sec / 60);
|
|
44
|
+
const ss = Math.floor(sec % 60);
|
|
45
|
+
return mm + ":" + String(ss).padStart(2, "0");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Component ────────────────────────────────────────────────────────────
|
|
49
|
+
interface LabelControlsProps {
|
|
50
|
+
playerRef: React.RefObject<any>;
|
|
51
|
+
/** Current player time in seconds (from onFrameUpdate, no polling needed) */
|
|
52
|
+
currentTime: number;
|
|
53
|
+
/** Called when scene/time changes, for HeaderBar */
|
|
54
|
+
onSceneChange?: (sceneInfo: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function LabelControls({ playerRef, currentTime, onSceneChange }: LabelControlsProps) {
|
|
58
|
+
const [videoInfo, setVideoInfo] = React.useState<VideoInfo | null>(null);
|
|
59
|
+
const [labelData, setLabelData] = React.useState<Record<number, LabelEntry>>({});
|
|
60
|
+
const [currentSceneIdx, setCurrentSceneIdx] = React.useState(0);
|
|
61
|
+
const [selectedOverride, setSelectedOverride] = React.useState(-1);
|
|
62
|
+
const [inputText, setInputText] = React.useState("");
|
|
63
|
+
const [saving, setSaving] = React.useState(false);
|
|
64
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
65
|
+
|
|
66
|
+
// ── Load video info and existing labels ──────────────────────────────
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
fetch("/api/video-info")
|
|
69
|
+
.then((r) => r.json())
|
|
70
|
+
.then((info) => {
|
|
71
|
+
setVideoInfo(info);
|
|
72
|
+
if (info.scenes?.[0]) {
|
|
73
|
+
onSceneChange?.(info.scenes[0].name + " (0.0s)");
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch(() => {});
|
|
77
|
+
|
|
78
|
+
fetch("/api/labels")
|
|
79
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
80
|
+
.then((tree) => {
|
|
81
|
+
if (!tree) return;
|
|
82
|
+
const root = tree.root || tree;
|
|
83
|
+
const children = root.children || [];
|
|
84
|
+
const loaded: Record<number, LabelEntry> = {};
|
|
85
|
+
for (let i = 0; i < children.length; i++) {
|
|
86
|
+
const media = (children[i].children || [])[0];
|
|
87
|
+
if (media && media.userHints) {
|
|
88
|
+
loaded[i] = {
|
|
89
|
+
overall: media.userHints.overall || "",
|
|
90
|
+
timed: media.userHints.timed || {},
|
|
91
|
+
};
|
|
92
|
+
} else if (media && media.description) {
|
|
93
|
+
loaded[i] = { overall: media.description, timed: {} };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
setLabelData(loaded);
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
}, [onSceneChange]);
|
|
100
|
+
|
|
101
|
+
// ── Compute current scene from time ──────────────────────────────────
|
|
102
|
+
const scenes = videoInfo?.scenes || [];
|
|
103
|
+
const effectiveIdx =
|
|
104
|
+
selectedOverride >= 0 ? selectedOverride : currentSceneIdx;
|
|
105
|
+
const currentScene = scenes[effectiveIdx];
|
|
106
|
+
|
|
107
|
+
// Update current scene index from player time (pushed by parent via onFrameUpdate)
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (selectedOverride >= 0) return;
|
|
110
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
111
|
+
if (currentTime >= scenes[i].start && currentTime < scenes[i].end) {
|
|
112
|
+
setCurrentSceneIdx(i);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}, [currentTime, scenes, selectedOverride]);
|
|
117
|
+
|
|
118
|
+
// Clear override when player moves away
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
if (selectedOverride >= 0 && currentScene) {
|
|
121
|
+
const scene = scenes[selectedOverride];
|
|
122
|
+
if (scene && (currentTime < scene.start || currentTime >= scene.end)) {
|
|
123
|
+
setSelectedOverride(-1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}, [currentTime, selectedOverride, scenes, currentScene]);
|
|
127
|
+
|
|
128
|
+
// Push scene info to header
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!currentScene) {
|
|
131
|
+
onSceneChange?.(currentTime.toFixed(1) + "s");
|
|
132
|
+
} else if (selectedOverride >= 0) {
|
|
133
|
+
onSceneChange?.(currentScene.name + " (selected)");
|
|
134
|
+
} else {
|
|
135
|
+
onSceneChange?.(currentScene.name + " (" + currentTime.toFixed(1) + "s)");
|
|
136
|
+
}
|
|
137
|
+
}, [currentScene, currentTime, selectedOverride, onSceneChange]);
|
|
138
|
+
|
|
139
|
+
// ── Seek to scene ────────────────────────────────────────────────────
|
|
140
|
+
const seekToScene = React.useCallback(
|
|
141
|
+
(index: number) => {
|
|
142
|
+
setSelectedOverride(index);
|
|
143
|
+
setCurrentSceneIdx(index);
|
|
144
|
+
const scene = scenes[index];
|
|
145
|
+
if (scene && playerRef.current) {
|
|
146
|
+
const frame = Math.round(scene.start * 30);
|
|
147
|
+
playerRef.current.seekTo(frame);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[scenes, playerRef]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── Update input when scene changes ──────────────────────────────────
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
const entry = labelData[effectiveIdx];
|
|
156
|
+
setInputText((entry && entry.overall) || "");
|
|
157
|
+
}, [effectiveIdx, labelData]);
|
|
158
|
+
|
|
159
|
+
// ── Save label ───────────────────────────────────────────────────────
|
|
160
|
+
const saveLabel = React.useCallback(() => {
|
|
161
|
+
const text = inputText.trim();
|
|
162
|
+
if (!text || saving) return;
|
|
163
|
+
setSaving(true);
|
|
164
|
+
|
|
165
|
+
const sceneIdx = effectiveIdx;
|
|
166
|
+
const timeMs = Math.round(currentTime * 1000);
|
|
167
|
+
const scene = scenes[sceneIdx];
|
|
168
|
+
const isImage = scene && scene.mediaType === "image";
|
|
169
|
+
const sceneStart = scene ? scene.start * 1000 : 0;
|
|
170
|
+
const isOverall = isImage || timeMs - sceneStart < 1000;
|
|
171
|
+
|
|
172
|
+
// Update local state
|
|
173
|
+
setLabelData((prev) => {
|
|
174
|
+
const next = { ...prev };
|
|
175
|
+
if (!next[sceneIdx]) next[sceneIdx] = { overall: "", timed: {} };
|
|
176
|
+
if (isOverall) {
|
|
177
|
+
next[sceneIdx] = { ...next[sceneIdx], overall: text };
|
|
178
|
+
} else {
|
|
179
|
+
next[sceneIdx] = {
|
|
180
|
+
...next[sceneIdx],
|
|
181
|
+
timed: { ...next[sceneIdx].timed, ["at_" + timeMs]: text },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Save to server
|
|
188
|
+
fetch("/api/labels", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
sceneIndex: sceneIdx,
|
|
193
|
+
description: text,
|
|
194
|
+
time: currentTime,
|
|
195
|
+
overall: isOverall || undefined,
|
|
196
|
+
}),
|
|
197
|
+
})
|
|
198
|
+
.then(() => {
|
|
199
|
+
setInputText("");
|
|
200
|
+
// Show toast
|
|
201
|
+
const toast = document.getElementById("saved-toast");
|
|
202
|
+
if (toast) {
|
|
203
|
+
toast.classList.add("show");
|
|
204
|
+
setTimeout(() => toast.classList.remove("show"), 2000);
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.catch(() => {})
|
|
208
|
+
.finally(() => setSaving(false));
|
|
209
|
+
}, [inputText, saving, effectiveIdx, currentTime, scenes]);
|
|
210
|
+
|
|
211
|
+
// ── Delete timed label ───────────────────────────────────────────────
|
|
212
|
+
const deleteTimed = React.useCallback(
|
|
213
|
+
(key: string) => {
|
|
214
|
+
const sceneIdx = effectiveIdx;
|
|
215
|
+
setLabelData((prev) => {
|
|
216
|
+
const entry = prev[sceneIdx];
|
|
217
|
+
if (!entry || !entry.timed[key]) return prev;
|
|
218
|
+
const next = { ...prev };
|
|
219
|
+
const nextEntry = { ...entry, timed: { ...entry.timed } };
|
|
220
|
+
delete nextEntry.timed[key];
|
|
221
|
+
next[sceneIdx] = nextEntry;
|
|
222
|
+
return next;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
fetch("/api/labels", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "Content-Type": "application/json" },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
sceneIndex: sceneIdx,
|
|
230
|
+
description: "",
|
|
231
|
+
removeTimed: key,
|
|
232
|
+
}),
|
|
233
|
+
}).catch(() => {});
|
|
234
|
+
},
|
|
235
|
+
[effectiveIdx]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// ── Render thumbnail strip ───────────────────────────────────────────
|
|
239
|
+
const renderThumbnails = () => {
|
|
240
|
+
return (
|
|
241
|
+
<div id="thumbnails">
|
|
242
|
+
{scenes.map((scene, i) => {
|
|
243
|
+
const isActive = i === effectiveIdx;
|
|
244
|
+
const entry = labelData[i];
|
|
245
|
+
const hasLabel =
|
|
246
|
+
entry && (entry.overall || Object.keys(entry.timed).length > 0);
|
|
247
|
+
|
|
248
|
+
let thumbContent: React.ReactNode;
|
|
249
|
+
const ext = scene.src.substring(scene.src.lastIndexOf(".")).toLowerCase();
|
|
250
|
+
const isVideo = !!VIDEO_EXT[ext];
|
|
251
|
+
|
|
252
|
+
if (scene.src && !isVideo) {
|
|
253
|
+
thumbContent = <img src={scene.src} alt="" loading="lazy" />;
|
|
254
|
+
} else if (scene.src && isVideo) {
|
|
255
|
+
thumbContent = (
|
|
256
|
+
<video
|
|
257
|
+
src={scene.src}
|
|
258
|
+
muted
|
|
259
|
+
preload="metadata"
|
|
260
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
thumbContent = (
|
|
265
|
+
<div
|
|
266
|
+
style={{
|
|
267
|
+
width: "100%", height: "100%", display: "flex",
|
|
268
|
+
alignItems: "center", justifyContent: "center",
|
|
269
|
+
background: "rgba(255,255,255,.08)",
|
|
270
|
+
color: "rgba(255,255,255,.3)",
|
|
271
|
+
fontSize: 16, fontWeight: 600,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
{(scene.name || "S" + (i + 1)).slice(0, 2).toUpperCase()}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
key={i}
|
|
282
|
+
className={"thumb-item" + (isActive ? " active" : "")}
|
|
283
|
+
data-index={i}
|
|
284
|
+
onClick={() => seekToScene(i)}
|
|
285
|
+
>
|
|
286
|
+
{thumbContent}
|
|
287
|
+
<div className={"thumb-badge" + (hasLabel ? " has-label" : "")} />
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
})}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ── Render timed labels list ─────────────────────────────────────────
|
|
296
|
+
const renderTimedLabels = () => {
|
|
297
|
+
const entry = labelData[effectiveIdx];
|
|
298
|
+
const timed = (entry && entry.timed) || {};
|
|
299
|
+
const keys = Object.keys(timed).sort();
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div id="timed-labels">
|
|
303
|
+
{keys.map((k) => {
|
|
304
|
+
const sec = parseInt(k.replace("at_", "")) / 1000;
|
|
305
|
+
return (
|
|
306
|
+
<div key={k} className="timed-label" data-key={k}>
|
|
307
|
+
<span className="tl-time">{formatTime(sec)}</span>
|
|
308
|
+
<span className="tl-text" dangerouslySetInnerHTML={{ __html: escHtml(timed[k]) }} />
|
|
309
|
+
<button className="tl-del" title="Remove" onClick={() => deleteTimed(k)}>
|
|
310
|
+
×
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
})}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// ── Scene info text ──────────────────────────────────────────────────
|
|
320
|
+
let sceneInfoText = currentTime.toFixed(1) + "s";
|
|
321
|
+
if (currentScene) {
|
|
322
|
+
if (selectedOverride >= 0) {
|
|
323
|
+
sceneInfoText = currentScene.name + " (selected)";
|
|
324
|
+
} else {
|
|
325
|
+
sceneInfoText = currentScene.name + " (" + currentTime.toFixed(1) + "s)";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Render ───────────────────────────────────────────────────────────
|
|
330
|
+
return (
|
|
331
|
+
<>
|
|
332
|
+
{/* Thumbnail strip */}
|
|
333
|
+
{renderThumbnails()}
|
|
334
|
+
|
|
335
|
+
{/* Timed labels list */}
|
|
336
|
+
{renderTimedLabels()}
|
|
337
|
+
|
|
338
|
+
{/* Saved toast */}
|
|
339
|
+
<div id="saved-toast">✓ Label saved</div>
|
|
340
|
+
|
|
341
|
+
{/* Bottom input bar */}
|
|
342
|
+
<div id="bottom-bar">
|
|
343
|
+
<input
|
|
344
|
+
ref={inputRef}
|
|
345
|
+
id="label-input"
|
|
346
|
+
placeholder="Add label for current scene…"
|
|
347
|
+
value={inputText}
|
|
348
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value)}
|
|
349
|
+
onKeyDown={(e: React.KeyboardEvent) => {
|
|
350
|
+
if (e.key === "Enter") {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
saveLabel();
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
<button
|
|
357
|
+
id="label-btn"
|
|
358
|
+
title="Save label"
|
|
359
|
+
disabled={saving || !inputText.trim()}
|
|
360
|
+
onClick={saveLabel}
|
|
361
|
+
>
|
|
362
|
+
📝
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneThumbnails — horizontal scene selector for all modes.
|
|
3
|
+
*
|
|
4
|
+
* Fetches scene info from /api/video-info and renders clickable thumbnails
|
|
5
|
+
* with scene names. Active scene is highlighted based on currentTime.
|
|
6
|
+
*
|
|
7
|
+
* Layout: supported in edit and preview modes (label mode has its own).
|
|
8
|
+
*/
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
interface Scene {
|
|
12
|
+
name: string;
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
duration: number;
|
|
16
|
+
src: string;
|
|
17
|
+
mediaType: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const VIDEO_EXT: Record<string, number> = {
|
|
21
|
+
".mov": 1, ".mp4": 1, ".avi": 1, ".mkv": 1, ".webm": 1, ".m4v": 1, ".wmv": 1,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface SceneThumbnailsProps {
|
|
25
|
+
/** Current player time in seconds — used to highlight active scene */
|
|
26
|
+
currentTime: number;
|
|
27
|
+
/** Called when a thumbnail is clicked; receives scene start time in seconds */
|
|
28
|
+
onSeek?: (timeSeconds: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
|
|
32
|
+
const [scenes, setScenes] = React.useState<Scene[]>([]);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
fetch("/api/video-info")
|
|
36
|
+
.then((r) => r.json())
|
|
37
|
+
.then((info) => {
|
|
38
|
+
if (info.scenes) setScenes(info.scenes);
|
|
39
|
+
})
|
|
40
|
+
.catch(() => {});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
if (scenes.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
// Determine active scene index from currentTime
|
|
46
|
+
let activeIdx = -1;
|
|
47
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
48
|
+
if (currentTime >= scenes[i].start && currentTime < scenes[i].end) {
|
|
49
|
+
activeIdx = i;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div id="scene-thumbnails">
|
|
56
|
+
{scenes.map((scene, i) => {
|
|
57
|
+
const isActive = i === activeIdx;
|
|
58
|
+
const ext = scene.src.substring(scene.src.lastIndexOf(".")).toLowerCase();
|
|
59
|
+
const isVideo = !!VIDEO_EXT[ext];
|
|
60
|
+
const hasMedia = !!scene.src;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
key={i}
|
|
65
|
+
className={"sthumb-item" + (isActive ? " active" : "")}
|
|
66
|
+
onClick={() => onSeek?.(scene.start)}
|
|
67
|
+
>
|
|
68
|
+
<div className="sthumb-media">
|
|
69
|
+
{hasMedia && !isVideo && (
|
|
70
|
+
<img src={scene.src} alt="" loading="lazy" />
|
|
71
|
+
)}
|
|
72
|
+
{hasMedia && isVideo && (
|
|
73
|
+
<video src={scene.src} muted preload="metadata" />
|
|
74
|
+
)}
|
|
75
|
+
{!hasMedia && (
|
|
76
|
+
<span className="sthumb-fallback">
|
|
77
|
+
{(scene.name || "S" + (i + 1)).slice(0, 2).toUpperCase()}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
<span className="sthumb-name">{scene.name || "Scene " + (i + 1)}</span>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VariantBar — variant switcher bar for the markcut player.
|
|
3
|
+
*
|
|
4
|
+
* Reads available variants from /api/video-info and renders clickable links.
|
|
5
|
+
* Highlights the current variant based on window.VARIANT.
|
|
6
|
+
*/
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
|
|
9
|
+
export function VariantBar() {
|
|
10
|
+
const [variants, setVariants] = React.useState<string[]>([]);
|
|
11
|
+
const currentVariant = (typeof window !== "undefined" ? (window as any).VARIANT : null) || "default";
|
|
12
|
+
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
fetch("/api/video-info")
|
|
15
|
+
.then((r) => r.json())
|
|
16
|
+
.then((info) => {
|
|
17
|
+
if (info.variants && info.variants.length > 1) {
|
|
18
|
+
setVariants(info.variants);
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
.catch(() => {});
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
if (variants.length <= 1) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div id="variant-bar">
|
|
28
|
+
{variants.map((v) => (
|
|
29
|
+
<a
|
|
30
|
+
key={v}
|
|
31
|
+
href={`/${v === "default" ? "" : v}`}
|
|
32
|
+
className={"variant-link" + (v === currentVariant ? " active" : "")}
|
|
33
|
+
>
|
|
34
|
+
{v}
|
|
35
|
+
</a>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|