@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
|
@@ -1,599 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Label preview server — player for scene labeling.
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* node src/player/label-server.mjs <input.json|.md> [--port 3031]
|
|
7
|
-
*
|
|
8
|
-
* Supports compiled stream trees, descriptive JSON, and markdown.
|
|
9
|
-
* Labels are saved to labels.json alongside the source.
|
|
10
|
-
*/
|
|
11
|
-
import { createServer } from "node:http";
|
|
12
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
13
|
-
import { resolve, dirname, join } from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { isDescriptiveRoot, resolveAndCompile, resolveAndCompileMarkdown } from "./pipeline.mjs";
|
|
16
|
-
import { extractScenes, MIME, serveFile, handleShutdown } from "./server-shared.mjs";
|
|
17
|
-
|
|
18
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
const ROOT = resolve(__dirname, "..", "..");
|
|
20
|
-
const PORT = parseInt(process.argv.find(a => a.startsWith("--port="))?.split("=")[1] || "3031", 10);
|
|
21
|
-
|
|
22
|
-
// Determine source: JSON or MD file (3rd+ arg, skip --flags)
|
|
23
|
-
const sourceArg = process.argv.slice(2).find(a => !a.startsWith("--"));
|
|
24
|
-
const SOURCE = sourceArg ? resolve(sourceArg) : resolve(".");
|
|
25
|
-
const IS_MARKDOWN = SOURCE.endsWith(".md");
|
|
26
|
-
|
|
27
|
-
// ─── extractScenes imported from ./server-shared.mjs ────────────────────
|
|
28
|
-
|
|
29
|
-
// ─── Load + compile (handles compiled JSON, descriptive JSON, markdown) ──
|
|
30
|
-
async function loadSource() {
|
|
31
|
-
const raw = readFileSync(SOURCE, "utf-8");
|
|
32
|
-
|
|
33
|
-
if (IS_MARKDOWN) {
|
|
34
|
-
console.log(" 📝 Detected markdown — parsing + compiling...");
|
|
35
|
-
return await resolveAndCompileMarkdown(raw, {
|
|
36
|
-
baseDir: dirname(SOURCE),
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const parsed = JSON.parse(raw);
|
|
41
|
-
const root = parsed.root || parsed;
|
|
42
|
-
|
|
43
|
-
if (isDescriptiveRoot(root)) {
|
|
44
|
-
console.log(" 🔍 Detected descriptive JSON — running pipeline...");
|
|
45
|
-
return await resolveAndCompile(root, {
|
|
46
|
-
baseDir: dirname(SOURCE),
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return root;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── Scene parsing ─────────────────────────────────────────────────────
|
|
54
|
-
let scenes = [];
|
|
55
|
-
let totalDuration = 0;
|
|
56
|
-
let videoData = null;
|
|
57
|
-
const LABELS_PATH = join(dirname(SOURCE), "labels.json");
|
|
58
|
-
/** Promise that resolves when the initial pipeline (TTS, STT, media) completes */
|
|
59
|
-
let bootstrap = Promise.resolve();
|
|
60
|
-
|
|
61
|
-
if (existsSync(SOURCE)) {
|
|
62
|
-
try {
|
|
63
|
-
// async bootstrap — we inline the promise since the server starts synchronously
|
|
64
|
-
bootstrap = loadSource().then((compiled) => {
|
|
65
|
-
videoData = compiled;
|
|
66
|
-
const extracted = extractScenes(compiled);
|
|
67
|
-
scenes = extracted.scenes;
|
|
68
|
-
totalDuration = extracted.totalDuration;
|
|
69
|
-
|
|
70
|
-
// If only scenes metadata (no compiled tree from labels.json), build a player tree
|
|
71
|
-
if (scenes.length > 0 && (!videoData.type || videoData.type !== "root")) {
|
|
72
|
-
videoData = {
|
|
73
|
-
id: "root",
|
|
74
|
-
type: "root",
|
|
75
|
-
width: 1080,
|
|
76
|
-
height: 1920,
|
|
77
|
-
fps: 30,
|
|
78
|
-
isSeries: true,
|
|
79
|
-
transition: "fade",
|
|
80
|
-
theme: "cinematic",
|
|
81
|
-
children: scenes.map((s, i) => ({
|
|
82
|
-
id: s.name || ("scene-" + (i+1)),
|
|
83
|
-
name: s.name || ("scene-" + (i+1)),
|
|
84
|
-
type: "folder",
|
|
85
|
-
isSeries: false,
|
|
86
|
-
children: [{
|
|
87
|
-
id: (s.name || "scene-"+(i+1)) + "-media",
|
|
88
|
-
type: s.mediaType === "video" ? "video" : "image",
|
|
89
|
-
src: s.src,
|
|
90
|
-
fit: "cover",
|
|
91
|
-
actions: [{ start: 0, end: s.duration }],
|
|
92
|
-
}],
|
|
93
|
-
})),
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Merge existing labels from labels.json into videoData
|
|
98
|
-
try {
|
|
99
|
-
const labelsRaw = readFileSync(LABELS_PATH, "utf-8");
|
|
100
|
-
const labelsData = JSON.parse(labelsRaw);
|
|
101
|
-
const labelsRoot = labelsData.root || labelsData;
|
|
102
|
-
if (labelsRoot.children) {
|
|
103
|
-
for (let i = 0; i < Math.min(labelsRoot.children.length, videoData.children.length); i++) {
|
|
104
|
-
const labelMedia = labelsRoot.children[i]?.children?.[0];
|
|
105
|
-
const targetMedia = videoData.children[i]?.children?.[0];
|
|
106
|
-
if (labelMedia && targetMedia && labelMedia.description) {
|
|
107
|
-
targetMedia.description = labelMedia.description;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// No existing labels file — that's fine
|
|
113
|
-
}
|
|
114
|
-
}).catch(e => {
|
|
115
|
-
console.error("Failed to load source:", e.message);
|
|
116
|
-
// Fallback: minimal data so the server starts
|
|
117
|
-
videoData = { id: "root", type: "root", width: 1080, height: 1920, fps: 30, isSeries: false, children: [] };
|
|
118
|
-
});
|
|
119
|
-
} catch (e) {
|
|
120
|
-
console.error("Warning: could not parse source:", e.message);
|
|
121
|
-
}
|
|
122
|
-
} else {
|
|
123
|
-
console.error(`Source not found: ${SOURCE}`);
|
|
124
|
-
process.exit(1);
|
|
125
|
-
}
|
|
126
|
-
// ─── MIME imported from ./server-shared.mjs ──────────────────────────────
|
|
127
|
-
|
|
128
|
-
// ─── HTML page ─────────────────────────────────────────────────────────
|
|
129
|
-
function getHtml() {
|
|
130
|
-
return `<!DOCTYPE html>
|
|
131
|
-
<html lang="en">
|
|
132
|
-
<head>
|
|
133
|
-
<meta charset="UTF-8">
|
|
134
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
135
|
-
<title>Label Preview</title>
|
|
136
|
-
<style>
|
|
137
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
138
|
-
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
|
|
139
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; align-items: center; }
|
|
140
|
-
#header { display: flex; align-items: center; justify-content: flex-end; width: 100%; max-width: 500px; padding: 8px 12px; flex-shrink: 0; gap: 8px; }
|
|
141
|
-
#scene-info { font-size: 11px; color: rgba(255,255,255,.4); flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
142
|
-
#close-btn { width: 22px; height: 22px; border-radius: 50%; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.3); color: rgba(255,255,255,.4); font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .15s; }
|
|
143
|
-
#close-btn:hover { background: rgba(255,60,60,.4); border-color: rgba(255,60,60,.5); color: #fff; }
|
|
144
|
-
#player-frame { flex: 1; width: 100%; max-width: 480px; min-height: 0; border-radius: 16px; overflow: hidden; border: 1px solid rgba(255,255,255,.08); background: #000; box-shadow: 0 4px 40px rgba(0,0,0,.6); margin: 0 12px; }
|
|
145
|
-
#root { width: 100%; height: 100%; }
|
|
146
|
-
#thumbnails { display: flex; gap: 6px; width: 100%; max-width: 500px; padding: 4px 12px; flex-shrink: 0; overflow-x: auto; scrollbar-width: thin; }
|
|
147
|
-
#thumbnails::-webkit-scrollbar { height: 4px; }
|
|
148
|
-
#thumbnails::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
|
149
|
-
.thumb-item { flex-shrink: 0; width: 64px; height: 48px; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: all .15s; position: relative; background: rgba(255,255,255,.05); }
|
|
150
|
-
.thumb-item:hover { border-color: rgba(74,158,255,.4); }
|
|
151
|
-
.thumb-item.active { border-color: #4a9eff; box-shadow: 0 0 8px rgba(74,158,255,.3); }
|
|
152
|
-
.thumb-item img { width: 100%; height: 100%; object-fit: cover; }
|
|
153
|
-
.thumb-item .thumb-badge { position: absolute; top: 2px; right: 2px; width: 10px; height: 10px; border-radius: 50%; background: #4ade80; border: 1px solid rgba(0,0,0,.4); display: none; }
|
|
154
|
-
.thumb-item .thumb-badge.has-label { display: block; }
|
|
155
|
-
#bottom-bar { display: flex; gap: 6px; align-items: center; width: 100%; max-width: 500px; padding: 8px 12px; flex-shrink: 0; }
|
|
156
|
-
#label-input { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.05); color: #eee; border-radius: 8px; font-size: 13px; outline: none; transition: border-color .15s; }
|
|
157
|
-
#label-input:focus { border-color: rgba(74,158,255,.5); }
|
|
158
|
-
#label-input::placeholder { color: rgba(255,255,255,.25); }
|
|
159
|
-
#label-btn { width: 32px; height: 32px; padding: 0; background: rgba(255,255,255,.06); color: rgba(255,255,255,.5); border: 1px solid rgba(255,255,255,.1); border-radius: 8px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all .15s; flex-shrink: 0; }
|
|
160
|
-
#timed-labels { width: 100%; max-width: 500px; padding: 2px 12px; flex-shrink: 0; display: flex; flex-direction: column; gap: 2px; max-height: 80px; overflow-y: auto; }
|
|
161
|
-
.timed-label { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(255,255,255,.04); font-size: 11px; color: rgba(255,255,255,.6); }
|
|
162
|
-
.timed-label .tl-time { flex-shrink: 0; font-family: monospace; font-size: 10px; color: rgba(74,158,255,.7); min-width: 32px; }
|
|
163
|
-
.timed-label .tl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
164
|
-
.timed-label .tl-del { width: 16px; height: 16px; border: none; background: rgba(255,60,60,.15); color: rgba(255,60,60,.5); border-radius: 3px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; padding: 0; line-height: 1; }
|
|
165
|
-
.timed-label .tl-del:hover { background: rgba(255,60,60,.3); color: #fff; }
|
|
166
|
-
#label-btn:hover { background: rgba(74,158,255,.2); border-color: rgba(74,158,255,.4); color: #4a9eff; }
|
|
167
|
-
#label-btn:disabled { opacity: 0.3; cursor: wait; }
|
|
168
|
-
#saved-toast { position: fixed; bottom: 70px; left: 50%; transform: translateX(-50%); background: rgba(74,222,128,.9); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 500; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; backdrop-filter: blur(8px); }
|
|
169
|
-
#saved-toast.show { opacity: 1; }
|
|
170
|
-
</style>
|
|
171
|
-
</head>
|
|
172
|
-
<body>
|
|
173
|
-
<div id="header">
|
|
174
|
-
<span id="scene-info"></span>
|
|
175
|
-
<div id="header-actions">
|
|
176
|
-
<button id="close-btn" title="Close">✕</button>
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
<div id="player-frame">
|
|
180
|
-
<div id="root"></div>
|
|
181
|
-
</div>
|
|
182
|
-
<div id="thumbnails"></div>
|
|
183
|
-
<div id="timed-labels"></div>
|
|
184
|
-
<div id="saved-toast">✓ Label saved</div>
|
|
185
|
-
<div id="bottom-bar">
|
|
186
|
-
<input id="label-input" placeholder="Add label for current scene…" />
|
|
187
|
-
<button id="label-btn" title="Save label">📝</button>
|
|
188
|
-
</div>
|
|
189
|
-
<script src="/player.js" type="module"></script>
|
|
190
|
-
<script>
|
|
191
|
-
// ─── Close button ─────────────────────────────────────────────────────
|
|
192
|
-
document.getElementById("close-btn")?.addEventListener("click", () => {
|
|
193
|
-
navigator.sendBeacon("/api/shutdown", "{}");
|
|
194
|
-
document.body.innerHTML = "<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</div>";
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// ─── State ────────────────────────────────────────────────────────────
|
|
198
|
-
// labelData[sceneIndex] = { overall: "...", timed: { at_XXXX: "..." } }
|
|
199
|
-
var labelData = {};
|
|
200
|
-
var currentTime = 0;
|
|
201
|
-
var currentSceneIndex = 0;
|
|
202
|
-
var lastRenderedSceneIndex = -1;
|
|
203
|
-
var selectedSceneOverride = -1;
|
|
204
|
-
var labelInput = document.getElementById("label-input");
|
|
205
|
-
var labelBtn = document.getElementById("label-btn");
|
|
206
|
-
var savedToast = document.getElementById("saved-toast");
|
|
207
|
-
var sceneInfo = document.getElementById("scene-info");
|
|
208
|
-
var cachedInfo = null;
|
|
209
|
-
|
|
210
|
-
// ─── Load existing labels from stream tree ───────────────────────────
|
|
211
|
-
fetch("/api/labels").then(function(r) {
|
|
212
|
-
if (r.ok) return r.json();
|
|
213
|
-
return null;
|
|
214
|
-
}).then(function(tree) {
|
|
215
|
-
if (tree) {
|
|
216
|
-
var root = tree.root || tree;
|
|
217
|
-
var children = root.children || [];
|
|
218
|
-
for (var i = 0; i < children.length; i++) {
|
|
219
|
-
var media = (children[i].children || [])[0];
|
|
220
|
-
if (media && media.userHints) {
|
|
221
|
-
labelData[i] = { overall: media.userHints.overall || "", timed: media.userHints.timed || {} };
|
|
222
|
-
} else if (media && media.description) {
|
|
223
|
-
labelData[i] = { overall: media.description, timed: {} };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
loadSceneInfo();
|
|
228
|
-
}).catch(function() {});
|
|
229
|
-
|
|
230
|
-
// ─── Scene info & thumbnails ─────────────────────────────────────────
|
|
231
|
-
function loadSceneInfo(refresh) {
|
|
232
|
-
if (cachedInfo && !refresh) {
|
|
233
|
-
updateSceneInfo(cachedInfo);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
fetch("/api/video-info").then(function(r) { return r.json(); }).then(function(info) {
|
|
237
|
-
if (info.scenes && info.totalDuration) {
|
|
238
|
-
cachedInfo = info;
|
|
239
|
-
updateSceneInfo(info);
|
|
240
|
-
}
|
|
241
|
-
}).catch(function() {
|
|
242
|
-
if (cachedInfo) updateSceneInfo(cachedInfo);
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function updateSceneInfo(info) {
|
|
247
|
-
if (!sceneInfo) return;
|
|
248
|
-
var scenes = info.scenes || [];
|
|
249
|
-
var prevIndex = currentSceneIndex;
|
|
250
|
-
if (selectedSceneOverride >= 0) {
|
|
251
|
-
var selScene = scenes[selectedSceneOverride];
|
|
252
|
-
if (selScene) {
|
|
253
|
-
currentSceneIndex = selectedSceneOverride;
|
|
254
|
-
sceneInfo.textContent = selScene.name + " (selected)";
|
|
255
|
-
if (prevIndex !== currentSceneIndex) { updateLabelInput(); updateTimedLabelsUI(); }
|
|
256
|
-
renderThumbnails(info);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
var currentScene = null;
|
|
261
|
-
for (var i = 0; i < scenes.length; i++) {
|
|
262
|
-
if (currentTime >= scenes[i].start && currentTime < scenes[i].end) {
|
|
263
|
-
currentScene = scenes[i];
|
|
264
|
-
currentSceneIndex = i;
|
|
265
|
-
break;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (currentScene) {
|
|
269
|
-
sceneInfo.textContent = currentScene.name + " (" + currentTime.toFixed(1) + "s)";
|
|
270
|
-
} else {
|
|
271
|
-
sceneInfo.textContent = currentTime.toFixed(1) + "s";
|
|
272
|
-
}
|
|
273
|
-
if (prevIndex !== currentSceneIndex) { updateLabelInput(); updateTimedLabelsUI(); }
|
|
274
|
-
// Only re-render thumbnails when scene index changes (prevents flashing)
|
|
275
|
-
if (prevIndex !== currentSceneIndex) renderThumbnails(info);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function renderThumbnails(info) {
|
|
279
|
-
var container = document.getElementById("thumbnails");
|
|
280
|
-
if (!container) return;
|
|
281
|
-
var scenes = info.scenes || [];
|
|
282
|
-
var html = "";
|
|
283
|
-
var isVideoExt = {".mov":1,".mp4":1,".avi":1,".mkv":1,".webm":1,".m4v":1,".wmv":1};
|
|
284
|
-
for (var i = 0; i < scenes.length; i++) {
|
|
285
|
-
var s = scenes[i];
|
|
286
|
-
var isActive = i === currentSceneIndex ? " active" : "";
|
|
287
|
-
var d = labelData[i];
|
|
288
|
-
var hasLabel = (d && (d.overall || Object.keys(d.timed).length > 0)) ? " has-label" : "";
|
|
289
|
-
var thumbSrc = s.src || "";
|
|
290
|
-
var ext = thumbSrc.substring(thumbSrc.lastIndexOf(".")).toLowerCase();
|
|
291
|
-
var isVideo = isVideoExt[ext] || false;
|
|
292
|
-
var img;
|
|
293
|
-
if (thumbSrc && !isVideo) {
|
|
294
|
-
img = "<img src='" + thumbSrc + "' alt='' loading='lazy' />";
|
|
295
|
-
} else if (thumbSrc && isVideo) {
|
|
296
|
-
// Use video element that auto-seeks to first frame as poster
|
|
297
|
-
img = "<video src='" + thumbSrc + "' muted preload='metadata' style='width:100%;height:100%;object-fit:cover'></video>";
|
|
298
|
-
} else {
|
|
299
|
-
img = "<div style='width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.08);color:rgba(255,255,255,.3);font-size:16px;font-weight:600'>" + (s.name || "S" + (i+1)).slice(0,2).toUpperCase() + "</div>";
|
|
300
|
-
}
|
|
301
|
-
html += "<div class='thumb-item" + isActive + "' data-index='" + i + "'>"
|
|
302
|
-
+ img
|
|
303
|
-
+ "<div class='thumb-badge" + hasLabel + "'></div>"
|
|
304
|
-
+ "</div>";
|
|
305
|
-
}
|
|
306
|
-
container.innerHTML = html;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ─── Helper: populate input with current scene's label ───────────────
|
|
310
|
-
function updateLabelInput() {
|
|
311
|
-
var data = labelData[currentSceneIndex];
|
|
312
|
-
if (labelInput) {
|
|
313
|
-
labelInput.value = (data && data.overall) || "";
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// ─── Update timed labels list UI ─────────────────────────────────────
|
|
318
|
-
function updateTimedLabelsUI() {
|
|
319
|
-
var container = document.getElementById("timed-labels");
|
|
320
|
-
if (!container) return;
|
|
321
|
-
var data = labelData[currentSceneIndex];
|
|
322
|
-
var timed = (data && data.timed) || {};
|
|
323
|
-
var keys = Object.keys(timed).sort();
|
|
324
|
-
var html = "";
|
|
325
|
-
for (var k of keys) {
|
|
326
|
-
var sec = parseInt(k.replace("at_", "")) / 1000;
|
|
327
|
-
var mm = Math.floor(sec / 60);
|
|
328
|
-
var ss = Math.floor(sec % 60);
|
|
329
|
-
html += "<div class='timed-label' data-key='" + k + "'><span class='tl-time'>" + mm + ":" + String(ss).padStart(2,"0") + "</span><span class='tl-text'>" + escapedHtml(timed[k]) + "</span><button class='tl-del' title='Remove' data-key='" + k + "'>\u00d7</button></div>";
|
|
330
|
-
}
|
|
331
|
-
container.innerHTML = html;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function escapedHtml(s) {
|
|
335
|
-
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ─── Thumbnail click delegation ──────────────────────────────────────
|
|
339
|
-
document.getElementById("thumbnails")?.addEventListener("click", function(e) {
|
|
340
|
-
var item = e.target.closest(".thumb-item");
|
|
341
|
-
if (item) {
|
|
342
|
-
var index = parseInt(item.getAttribute("data-index"), 10);
|
|
343
|
-
if (!isNaN(index)) seekToScene(index);
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// ─── Seek to scene via player API ────────────────────────────────────
|
|
348
|
-
function seekToScene(index) {
|
|
349
|
-
selectedSceneOverride = index;
|
|
350
|
-
currentSceneIndex = index;
|
|
351
|
-
updateLabelInput();
|
|
352
|
-
updateTimedLabelsUI();
|
|
353
|
-
loadSceneInfo();
|
|
354
|
-
// Seek player using exposed API
|
|
355
|
-
fetch("/api/video-info").then(function(r) { return r.json(); }).then(function(info) {
|
|
356
|
-
var scene = (info.scenes || [])[index];
|
|
357
|
-
if (scene && window.__remotionSeekTo) {
|
|
358
|
-
window.__remotionSeekTo(scene.start);
|
|
359
|
-
}
|
|
360
|
-
}).catch(function() {});
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ─── Poll player time ────────────────────────────────────────────────
|
|
364
|
-
setInterval(function() {
|
|
365
|
-
var playerText = document.getElementById("player-frame")?.textContent || "";
|
|
366
|
-
var match = playerText.match(/(\\d+):(\\d+)\\s*\\//);
|
|
367
|
-
if (match) {
|
|
368
|
-
var prevTime = currentTime;
|
|
369
|
-
currentTime = parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
|
|
370
|
-
if (prevTime !== currentTime && prevTime > 0 && selectedSceneOverride >= 0) {
|
|
371
|
-
selectedSceneOverride = -1;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
loadSceneInfo();
|
|
375
|
-
}, 1000);
|
|
376
|
-
|
|
377
|
-
// ─── Label save ──────────────────────────────────────────────────────
|
|
378
|
-
function showSavedToast() {
|
|
379
|
-
savedToast?.classList.add("show");
|
|
380
|
-
setTimeout(function() { savedToast?.classList.remove("show"); }, 2000);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function saveLabel() {
|
|
384
|
-
var text = labelInput.value.trim();
|
|
385
|
-
if (!text) return;
|
|
386
|
-
labelBtn.disabled = true;
|
|
387
|
-
|
|
388
|
-
var sceneIndex = currentSceneIndex;
|
|
389
|
-
var timeMs = Math.round(currentTime * 1000);
|
|
390
|
-
|
|
391
|
-
// Update local state
|
|
392
|
-
if (!labelData[sceneIndex]) labelData[sceneIndex] = { overall: "", timed: {} };
|
|
393
|
-
// If we're near the start of a scene or it's the first label, save as overall
|
|
394
|
-
// Otherwise save as timed (at the current position)
|
|
395
|
-
var scenes = cachedInfo ? cachedInfo.scenes : [];
|
|
396
|
-
var scene = scenes[sceneIndex];
|
|
397
|
-
var isImage = scene && scene.mediaType === "image";
|
|
398
|
-
var sceneStart = scene ? scene.start * 1000 : 0;
|
|
399
|
-
// Within first 1s of scene or image → overall; else → timed
|
|
400
|
-
if (isImage || timeMs - sceneStart < 1000) {
|
|
401
|
-
labelData[sceneIndex].overall = text;
|
|
402
|
-
} else {
|
|
403
|
-
labelData[sceneIndex].timed["at_" + timeMs] = text;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
fetch("/api/labels", {
|
|
407
|
-
method: "POST",
|
|
408
|
-
headers: { "Content-Type": "application/json" },
|
|
409
|
-
body: JSON.stringify({
|
|
410
|
-
sceneIndex: sceneIndex,
|
|
411
|
-
description: text,
|
|
412
|
-
time: currentTime,
|
|
413
|
-
overall: !(isImage || timeMs - sceneStart < 1000) ? undefined : true,
|
|
414
|
-
}),
|
|
415
|
-
}).then(function(res) {
|
|
416
|
-
if (res.ok) {
|
|
417
|
-
labelInput.value = "";
|
|
418
|
-
showSavedToast();
|
|
419
|
-
updateTimedLabelsUI();
|
|
420
|
-
renderThumbnails(cachedInfo);
|
|
421
|
-
}
|
|
422
|
-
}).catch(function(e) {
|
|
423
|
-
console.error("Failed to save label:", e);
|
|
424
|
-
}).finally(function() {
|
|
425
|
-
labelBtn.disabled = false;
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
labelBtn?.addEventListener("click", saveLabel);
|
|
430
|
-
labelInput?.addEventListener("keydown", function(e) {
|
|
431
|
-
if (e.key === "Enter") { e.preventDefault(); saveLabel(); }
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
// ─── Timed label delete delegation ───────────────────────────────────
|
|
435
|
-
document.addEventListener("click", function(e) {
|
|
436
|
-
var delBtn = e.target.closest(".tl-del");
|
|
437
|
-
if (!delBtn) return;
|
|
438
|
-
var key = delBtn.getAttribute("data-key");
|
|
439
|
-
if (!key) return;
|
|
440
|
-
var sceneIndex = currentSceneIndex;
|
|
441
|
-
if (labelData[sceneIndex] && labelData[sceneIndex].timed[key]) {
|
|
442
|
-
delete labelData[sceneIndex].timed[key];
|
|
443
|
-
// Sync delete to server
|
|
444
|
-
fetch("/api/labels", {
|
|
445
|
-
method: "POST",
|
|
446
|
-
headers: { "Content-Type": "application/json" },
|
|
447
|
-
body: JSON.stringify({
|
|
448
|
-
sceneIndex: sceneIndex,
|
|
449
|
-
description: labelData[sceneIndex].overall || "",
|
|
450
|
-
removeTimed: key,
|
|
451
|
-
}),
|
|
452
|
-
}).then(function(res) {
|
|
453
|
-
if (res.ok) updateTimedLabelsUI();
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
});
|
|
457
|
-
</script>
|
|
458
|
-
</body>
|
|
459
|
-
</html>`;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// ─── HTTP Server ──────────────────────────────────────────────────────────
|
|
463
|
-
const server = createServer((req, res) => {
|
|
464
|
-
const url = new URL(req.url, "http://localhost");
|
|
465
|
-
const path = url.pathname;
|
|
466
|
-
|
|
467
|
-
try {
|
|
468
|
-
// API: Load/save labels
|
|
469
|
-
if (path === "/api/labels") {
|
|
470
|
-
if (req.method === "GET") {
|
|
471
|
-
// Return the stream tree — descriptions on children are the labels
|
|
472
|
-
let body;
|
|
473
|
-
try {
|
|
474
|
-
body = readFileSync(LABELS_PATH, "utf-8");
|
|
475
|
-
// Validate it's a stream tree; if not, fall back
|
|
476
|
-
const parsed = JSON.parse(body);
|
|
477
|
-
if (!parsed.type && !parsed.root) throw new Error("not a stream tree");
|
|
478
|
-
} catch {
|
|
479
|
-
// Return current videoData (without descriptions) as the label tree
|
|
480
|
-
body = JSON.stringify(videoData);
|
|
481
|
-
}
|
|
482
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
483
|
-
res.end(body);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
if (req.method === "POST") {
|
|
487
|
-
let body = "";
|
|
488
|
-
req.on("data", c => body += c);
|
|
489
|
-
req.on("end", () => {
|
|
490
|
-
try {
|
|
491
|
-
const data = JSON.parse(body);
|
|
492
|
-
// data is { sceneIndex, description, time?, overall?, removeTimed? }
|
|
493
|
-
const { sceneIndex, description, time, overall, removeTimed } = data;
|
|
494
|
-
if (typeof sceneIndex === "number") {
|
|
495
|
-
const child = videoData?.children?.[sceneIndex];
|
|
496
|
-
const media = child?.children?.[0];
|
|
497
|
-
if (media) {
|
|
498
|
-
// Initialize userHints if needed
|
|
499
|
-
media.userHints = media.userHints || { overall: "", timed: {} };
|
|
500
|
-
if (removeTimed) {
|
|
501
|
-
delete media.userHints.timed[removeTimed];
|
|
502
|
-
} else if (typeof description === "string") {
|
|
503
|
-
if (overall) {
|
|
504
|
-
media.userHints.overall = description;
|
|
505
|
-
} else {
|
|
506
|
-
const timeMs = Math.round((time || 0) * 1000);
|
|
507
|
-
media.userHints.timed["at_" + timeMs] = description;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
// Keep description for backward compat
|
|
511
|
-
media.description = media.userHints.overall || Object.values(media.userHints.timed)[0] || undefined;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
// Save the full stream tree to labels.json
|
|
515
|
-
writeFileSync(LABELS_PATH, JSON.stringify(videoData, null, 2), "utf-8");
|
|
516
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
517
|
-
res.end(JSON.stringify({ saved: true }));
|
|
518
|
-
} catch (e) {
|
|
519
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
520
|
-
res.end(JSON.stringify({ error: e.message }));
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// API: Video data for player
|
|
528
|
-
if (path === "/api/video-data") {
|
|
529
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
530
|
-
res.end(JSON.stringify(videoData));
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// API: Scene info
|
|
535
|
-
if (path === "/api/video-info") {
|
|
536
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
537
|
-
res.end(JSON.stringify({ scenes, totalDuration, mode: { label: true } }));
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// API: Shutdown
|
|
542
|
-
if (path === "/api/shutdown") {
|
|
543
|
-
handleShutdown(req, res, "Shutdown requested");
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Serve player bundle
|
|
548
|
-
if (path === "/player.js") {
|
|
549
|
-
const bundlePath = join(ROOT, "src", "player", "bundle", "player.js");
|
|
550
|
-
if (existsSync(bundlePath)) {
|
|
551
|
-
const data = readFileSync(bundlePath);
|
|
552
|
-
res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-cache" });
|
|
553
|
-
res.end(data);
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Serve static files
|
|
559
|
-
if (path === "/" || path === "/index.html") {
|
|
560
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
561
|
-
res.end(getHtml());
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Serve from public/ (with range request support for video/audio)
|
|
566
|
-
const publicPath = join(ROOT, "public", path);
|
|
567
|
-
if (serveFile(req, res, publicPath)) return;
|
|
568
|
-
|
|
569
|
-
// Fallback: serve from the source file's directory (for media files referenced in preview)
|
|
570
|
-
if (SOURCE !== "." && existsSync(SOURCE)) {
|
|
571
|
-
const mediaPath = join(dirname(SOURCE), path);
|
|
572
|
-
if (serveFile(req, res, mediaPath)) return;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
res.writeHead(404);
|
|
576
|
-
res.end("Not found");
|
|
577
|
-
} catch (err) {
|
|
578
|
-
try {
|
|
579
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
580
|
-
res.end("Server error: " + err.message);
|
|
581
|
-
} catch (e2) {
|
|
582
|
-
console.error("Request error:", path, err.message);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
server.listen(PORT, async () => {
|
|
588
|
-
// Wait for initial pipeline to fully resolve before announcing ready
|
|
589
|
-
try {
|
|
590
|
-
await bootstrap;
|
|
591
|
-
} catch {
|
|
592
|
-
// Pipeline failed — still announce ready
|
|
593
|
-
}
|
|
594
|
-
console.log(`\n🏷️ Label Preview at http://localhost:${PORT}`);
|
|
595
|
-
console.log(` Source: 📄 ${SOURCE}`);
|
|
596
|
-
console.log(` Scenes: ${scenes.length}`);
|
|
597
|
-
console.log(` Labels: ${LABELS_PATH}`);
|
|
598
|
-
console.log("");
|
|
599
|
-
});
|