reviw 0.17.4 → 0.18.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/cli.cjs +841 -29
- package/package.json +1 -1
package/cli.cjs
CHANGED
|
@@ -18,6 +18,254 @@ const path = require("path");
|
|
|
18
18
|
const crypto = require("crypto");
|
|
19
19
|
const { spawn, execSync, spawnSync } = require("child_process");
|
|
20
20
|
const chardet = require("chardet");
|
|
21
|
+
|
|
22
|
+
// --- ffmpeg availability check for video timeline feature ---
|
|
23
|
+
let ffmpegAvailable = null;
|
|
24
|
+
function checkFfmpegAvailable() {
|
|
25
|
+
if (ffmpegAvailable !== null) return ffmpegAvailable;
|
|
26
|
+
try {
|
|
27
|
+
execSync('ffmpeg -version', { stdio: 'pipe' });
|
|
28
|
+
ffmpegAvailable = true;
|
|
29
|
+
} catch {
|
|
30
|
+
ffmpegAvailable = false;
|
|
31
|
+
}
|
|
32
|
+
return ffmpegAvailable;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Track temporary directories for timeline thumbnails (cleanup on exit)
|
|
36
|
+
const timelineTempDirs = new Set();
|
|
37
|
+
|
|
38
|
+
// Extract video timeline thumbnails using ffmpeg scene detection
|
|
39
|
+
// Uses "stabilization filter" - groups consecutive similar frames and takes the last frame of each group
|
|
40
|
+
function extractVideoTimeline(videoPath, tmpDir, res, onComplete) {
|
|
41
|
+
const STABILIZATION_THRESHOLD = 0.5; // seconds - consecutive changes within this are grouped
|
|
42
|
+
const SCENE_THRESHOLD = 0.01; // Very low threshold to catch subtle color changes
|
|
43
|
+
const MIN_INTERVAL = 1.0; // Minimum interval for additional keyframes (seconds)
|
|
44
|
+
|
|
45
|
+
// First, get video duration using ffprobe
|
|
46
|
+
let videoDuration = 0;
|
|
47
|
+
try {
|
|
48
|
+
const durationResult = spawnSync('ffprobe', [
|
|
49
|
+
'-v', 'error',
|
|
50
|
+
'-show_entries', 'format=duration',
|
|
51
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
52
|
+
videoPath
|
|
53
|
+
], { encoding: 'utf8' });
|
|
54
|
+
videoDuration = parseFloat(durationResult.stdout.trim()) || 0;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Ignore duration detection errors
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 1: Extract first frame (always include start)
|
|
60
|
+
const firstFramePath = `${tmpDir}/first_frame.jpg`;
|
|
61
|
+
spawnSync('ffmpeg', [
|
|
62
|
+
'-y', '-i', videoPath,
|
|
63
|
+
'-vf', 'scale=160:-1',
|
|
64
|
+
'-vframes', '1',
|
|
65
|
+
'-q:v', '5',
|
|
66
|
+
firstFramePath
|
|
67
|
+
], { stdio: 'pipe' });
|
|
68
|
+
|
|
69
|
+
// Step 2: Extract last frame (always include end)
|
|
70
|
+
const lastFramePath = `${tmpDir}/last_frame.jpg`;
|
|
71
|
+
if (videoDuration > 0) {
|
|
72
|
+
const lastTime = Math.max(0, videoDuration - 0.1);
|
|
73
|
+
spawnSync('ffmpeg', [
|
|
74
|
+
'-y', '-i', videoPath,
|
|
75
|
+
'-ss', String(lastTime),
|
|
76
|
+
'-vf', 'scale=160:-1',
|
|
77
|
+
'-vframes', '1',
|
|
78
|
+
'-q:v', '5',
|
|
79
|
+
lastFramePath
|
|
80
|
+
], { stdio: 'pipe' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 3: Use ffmpeg with scene detection filter (low threshold for color changes)
|
|
84
|
+
// select='gt(scene,0.01)' detects frames where scene change is > 1%
|
|
85
|
+
const ffmpeg = spawn('ffmpeg', [
|
|
86
|
+
'-i', videoPath,
|
|
87
|
+
'-vf', `select='gt(scene,${SCENE_THRESHOLD})',showinfo,scale=160:-1`,
|
|
88
|
+
'-vsync', 'vfr',
|
|
89
|
+
'-q:v', '5',
|
|
90
|
+
`${tmpDir}/scene_%04d.jpg`
|
|
91
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
92
|
+
|
|
93
|
+
let stderrBuffer = '';
|
|
94
|
+
const sceneTimestamps = [];
|
|
95
|
+
|
|
96
|
+
ffmpeg.stderr.on('data', (data) => {
|
|
97
|
+
stderrBuffer += data.toString();
|
|
98
|
+
|
|
99
|
+
// Parse showinfo output to extract timestamps
|
|
100
|
+
// Format: [Parsed_showinfo_1 @ ...] n: 0 pts: 0 pts_time:0
|
|
101
|
+
const lines = stderrBuffer.split('\n');
|
|
102
|
+
stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
103
|
+
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const match = line.match(/pts_time:([0-9.]+)/);
|
|
106
|
+
if (match) {
|
|
107
|
+
const timestamp = parseFloat(match[1]);
|
|
108
|
+
sceneTimestamps.push(timestamp);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
ffmpeg.on('close', (code) => {
|
|
114
|
+
// Even if ffmpeg fails, we still have first/last frames
|
|
115
|
+
|
|
116
|
+
// Apply stabilization filter: group consecutive frames within threshold
|
|
117
|
+
const stabilizedScenes = applyStabilizationFilter(sceneTimestamps, STABILIZATION_THRESHOLD);
|
|
118
|
+
|
|
119
|
+
// Collect all thumbnails with their timestamps
|
|
120
|
+
const allThumbnails = [];
|
|
121
|
+
|
|
122
|
+
// Add first frame (time: 0)
|
|
123
|
+
if (fs.existsSync(firstFramePath)) {
|
|
124
|
+
allThumbnails.push({ path: firstFramePath, time: 0 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Add scene-detected frames
|
|
128
|
+
const sceneFiles = fs.readdirSync(tmpDir)
|
|
129
|
+
.filter(f => f.startsWith('scene_') && f.endsWith('.jpg'))
|
|
130
|
+
.sort();
|
|
131
|
+
|
|
132
|
+
sceneFiles.forEach((file, idx) => {
|
|
133
|
+
const timestamp = sceneTimestamps[idx];
|
|
134
|
+
if (timestamp !== undefined && stabilizedScenes.includes(timestamp)) {
|
|
135
|
+
allThumbnails.push({
|
|
136
|
+
path: `${tmpDir}/${file}`,
|
|
137
|
+
time: timestamp
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Add last frame (time: videoDuration)
|
|
143
|
+
if (videoDuration > 0 && fs.existsSync(lastFramePath)) {
|
|
144
|
+
// Only add if it's not too close to existing thumbnails
|
|
145
|
+
const lastThumbTime = allThumbnails.length > 0
|
|
146
|
+
? allThumbnails[allThumbnails.length - 1].time
|
|
147
|
+
: 0;
|
|
148
|
+
if (videoDuration - lastThumbTime > STABILIZATION_THRESHOLD) {
|
|
149
|
+
allThumbnails.push({ path: lastFramePath, time: videoDuration });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// If we have very few thumbnails for a long video, add interval-based ones
|
|
154
|
+
if (videoDuration > 3 && allThumbnails.length < 3) {
|
|
155
|
+
// Extract frames at regular intervals
|
|
156
|
+
const intervalCount = Math.min(5, Math.floor(videoDuration / MIN_INTERVAL));
|
|
157
|
+
for (let i = 1; i < intervalCount; i++) {
|
|
158
|
+
const time = (videoDuration / intervalCount) * i;
|
|
159
|
+
// Check if we already have a thumbnail close to this time
|
|
160
|
+
const hasNearby = allThumbnails.some(t => Math.abs(t.time - time) < STABILIZATION_THRESHOLD);
|
|
161
|
+
if (!hasNearby) {
|
|
162
|
+
const intervalPath = `${tmpDir}/interval_${i}.jpg`;
|
|
163
|
+
const result = spawnSync('ffmpeg', [
|
|
164
|
+
'-y', '-i', videoPath,
|
|
165
|
+
'-ss', String(time),
|
|
166
|
+
'-vf', 'scale=160:-1',
|
|
167
|
+
'-vframes', '1',
|
|
168
|
+
'-q:v', '5',
|
|
169
|
+
intervalPath
|
|
170
|
+
], { stdio: 'pipe' });
|
|
171
|
+
if (result.status === 0 && fs.existsSync(intervalPath)) {
|
|
172
|
+
allThumbnails.push({ path: intervalPath, time: time });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Sort by time
|
|
179
|
+
allThumbnails.sort((a, b) => a.time - b.time);
|
|
180
|
+
|
|
181
|
+
// Remove duplicates (same time within threshold)
|
|
182
|
+
const uniqueThumbnails = [];
|
|
183
|
+
for (const thumb of allThumbnails) {
|
|
184
|
+
const isDuplicate = uniqueThumbnails.some(t => Math.abs(t.time - thumb.time) < 0.1);
|
|
185
|
+
if (!isDuplicate) {
|
|
186
|
+
uniqueThumbnails.push(thumb);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Send each thumbnail via SSE
|
|
191
|
+
uniqueThumbnails.forEach((thumb, idx) => {
|
|
192
|
+
if (fs.existsSync(thumb.path)) {
|
|
193
|
+
const imageData = fs.readFileSync(thumb.path);
|
|
194
|
+
const base64 = imageData.toString('base64');
|
|
195
|
+
res.write(`data: ${JSON.stringify({
|
|
196
|
+
type: "thumbnail",
|
|
197
|
+
time: thumb.time,
|
|
198
|
+
index: idx,
|
|
199
|
+
data: `data:image/jpeg;base64,${base64}`
|
|
200
|
+
})}\n\n`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Send completion message
|
|
205
|
+
res.write(`data: ${JSON.stringify({
|
|
206
|
+
type: "complete",
|
|
207
|
+
total: uniqueThumbnails.length,
|
|
208
|
+
duration: videoDuration
|
|
209
|
+
})}\n\n`);
|
|
210
|
+
res.end();
|
|
211
|
+
onComplete();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
ffmpeg.on('error', (err) => {
|
|
215
|
+
// Try to send at least first/last frames even on spawn error
|
|
216
|
+
const fallbackThumbnails = [];
|
|
217
|
+
if (fs.existsSync(firstFramePath)) {
|
|
218
|
+
fallbackThumbnails.push({ path: firstFramePath, time: 0 });
|
|
219
|
+
}
|
|
220
|
+
if (fs.existsSync(lastFramePath)) {
|
|
221
|
+
fallbackThumbnails.push({ path: lastFramePath, time: videoDuration });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fallbackThumbnails.forEach((thumb, idx) => {
|
|
225
|
+
const imageData = fs.readFileSync(thumb.path);
|
|
226
|
+
const base64 = imageData.toString('base64');
|
|
227
|
+
res.write(`data: ${JSON.stringify({
|
|
228
|
+
type: "thumbnail",
|
|
229
|
+
time: thumb.time,
|
|
230
|
+
index: idx,
|
|
231
|
+
data: `data:image/jpeg;base64,${base64}`
|
|
232
|
+
})}\n\n`);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
res.write(`data: ${JSON.stringify({
|
|
236
|
+
type: "complete",
|
|
237
|
+
total: fallbackThumbnails.length,
|
|
238
|
+
duration: videoDuration
|
|
239
|
+
})}\n\n`);
|
|
240
|
+
res.end();
|
|
241
|
+
onComplete();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Stabilization filter: groups consecutive timestamps within threshold and returns the last of each group
|
|
246
|
+
function applyStabilizationFilter(timestamps, threshold) {
|
|
247
|
+
if (timestamps.length === 0) return [];
|
|
248
|
+
if (timestamps.length === 1) return timestamps;
|
|
249
|
+
|
|
250
|
+
const sorted = [...timestamps].sort((a, b) => a - b);
|
|
251
|
+
const result = [];
|
|
252
|
+
let groupStart = 0;
|
|
253
|
+
|
|
254
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
255
|
+
// If gap between current and previous is larger than threshold, end the group
|
|
256
|
+
if (sorted[i] - sorted[i - 1] > threshold) {
|
|
257
|
+
// Take the last frame of the current group
|
|
258
|
+
result.push(sorted[i - 1]);
|
|
259
|
+
groupStart = i;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Always include the last frame of the final group
|
|
264
|
+
result.push(sorted[sorted.length - 1]);
|
|
265
|
+
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
21
269
|
const iconv = require("iconv-lite");
|
|
22
270
|
const marked = require("marked");
|
|
23
271
|
const yaml = require("js-yaml");
|
|
@@ -3088,11 +3336,141 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
|
|
|
3088
3336
|
align-items: center;
|
|
3089
3337
|
}
|
|
3090
3338
|
.video-container video {
|
|
3091
|
-
|
|
3092
|
-
|
|
3339
|
+
width: 100%;
|
|
3340
|
+
height: 100%;
|
|
3341
|
+
object-fit: contain;
|
|
3093
3342
|
border-radius: 8px;
|
|
3094
3343
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
3095
3344
|
}
|
|
3345
|
+
/* Video Timeline */
|
|
3346
|
+
.video-timeline {
|
|
3347
|
+
position: absolute;
|
|
3348
|
+
bottom: 0;
|
|
3349
|
+
left: 0;
|
|
3350
|
+
right: 0;
|
|
3351
|
+
height: 80px;
|
|
3352
|
+
background: rgba(0, 0, 0, 0.85);
|
|
3353
|
+
display: flex;
|
|
3354
|
+
overflow-x: auto;
|
|
3355
|
+
padding: 8px;
|
|
3356
|
+
gap: 4px;
|
|
3357
|
+
backdrop-filter: blur(8px);
|
|
3358
|
+
z-index: 5;
|
|
3359
|
+
}
|
|
3360
|
+
.video-timeline::-webkit-scrollbar {
|
|
3361
|
+
height: 6px;
|
|
3362
|
+
}
|
|
3363
|
+
.video-timeline::-webkit-scrollbar-track {
|
|
3364
|
+
background: rgba(255, 255, 255, 0.1);
|
|
3365
|
+
border-radius: 3px;
|
|
3366
|
+
}
|
|
3367
|
+
.video-timeline::-webkit-scrollbar-thumb {
|
|
3368
|
+
background: rgba(255, 255, 255, 0.3);
|
|
3369
|
+
border-radius: 3px;
|
|
3370
|
+
}
|
|
3371
|
+
.video-timeline::-webkit-scrollbar-thumb:hover {
|
|
3372
|
+
background: rgba(255, 255, 255, 0.5);
|
|
3373
|
+
}
|
|
3374
|
+
.timeline-thumb {
|
|
3375
|
+
height: 64px;
|
|
3376
|
+
cursor: pointer;
|
|
3377
|
+
border: 2px solid transparent;
|
|
3378
|
+
border-radius: 4px;
|
|
3379
|
+
flex-shrink: 0;
|
|
3380
|
+
transition: border-color 0.2s, transform 0.15s;
|
|
3381
|
+
opacity: 0.85;
|
|
3382
|
+
}
|
|
3383
|
+
.timeline-thumb:hover {
|
|
3384
|
+
border-color: rgba(59, 130, 246, 0.5);
|
|
3385
|
+
opacity: 1;
|
|
3386
|
+
transform: scale(1.05);
|
|
3387
|
+
}
|
|
3388
|
+
.timeline-thumb.active {
|
|
3389
|
+
border-color: #3b82f6;
|
|
3390
|
+
opacity: 1;
|
|
3391
|
+
box-shadow: 0 0 12px rgba(59, 130, 246, 0.5);
|
|
3392
|
+
}
|
|
3393
|
+
.timeline-loading {
|
|
3394
|
+
color: rgba(255, 255, 255, 0.6);
|
|
3395
|
+
font-size: 12px;
|
|
3396
|
+
padding: 8px 12px;
|
|
3397
|
+
display: flex;
|
|
3398
|
+
align-items: center;
|
|
3399
|
+
gap: 8px;
|
|
3400
|
+
}
|
|
3401
|
+
.timeline-loading::before {
|
|
3402
|
+
content: '';
|
|
3403
|
+
width: 14px;
|
|
3404
|
+
height: 14px;
|
|
3405
|
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
3406
|
+
border-top-color: #3b82f6;
|
|
3407
|
+
border-radius: 50%;
|
|
3408
|
+
animation: timeline-spin 0.8s linear infinite;
|
|
3409
|
+
}
|
|
3410
|
+
@keyframes timeline-spin {
|
|
3411
|
+
to { transform: rotate(360deg); }
|
|
3412
|
+
}
|
|
3413
|
+
.timeline-time {
|
|
3414
|
+
position: absolute;
|
|
3415
|
+
bottom: 2px;
|
|
3416
|
+
right: 4px;
|
|
3417
|
+
font-size: 9px;
|
|
3418
|
+
color: #fff;
|
|
3419
|
+
background: rgba(0, 0, 0, 0.7);
|
|
3420
|
+
padding: 1px 4px;
|
|
3421
|
+
border-radius: 2px;
|
|
3422
|
+
pointer-events: none;
|
|
3423
|
+
}
|
|
3424
|
+
.timeline-thumb-wrapper {
|
|
3425
|
+
position: relative;
|
|
3426
|
+
flex-shrink: 0;
|
|
3427
|
+
}
|
|
3428
|
+
/* Video Shortcuts Help */
|
|
3429
|
+
.video-shortcuts-help {
|
|
3430
|
+
opacity: 0.85;
|
|
3431
|
+
transition: opacity 0.2s;
|
|
3432
|
+
}
|
|
3433
|
+
.video-shortcuts-help:hover {
|
|
3434
|
+
opacity: 1;
|
|
3435
|
+
}
|
|
3436
|
+
.video-shortcuts-help .shortcuts-title {
|
|
3437
|
+
font-weight: 600;
|
|
3438
|
+
font-size: 11px;
|
|
3439
|
+
text-transform: uppercase;
|
|
3440
|
+
letter-spacing: 0.5px;
|
|
3441
|
+
margin-bottom: 10px;
|
|
3442
|
+
color: rgba(255, 255, 255, 0.7);
|
|
3443
|
+
display: flex;
|
|
3444
|
+
align-items: center;
|
|
3445
|
+
gap: 6px;
|
|
3446
|
+
}
|
|
3447
|
+
.video-shortcuts-help .shortcuts-title::before {
|
|
3448
|
+
content: '\u2328';
|
|
3449
|
+
font-size: 14px;
|
|
3450
|
+
}
|
|
3451
|
+
.video-shortcuts-help .shortcut-item {
|
|
3452
|
+
margin-bottom: 6px;
|
|
3453
|
+
display: flex;
|
|
3454
|
+
align-items: center;
|
|
3455
|
+
gap: 4px;
|
|
3456
|
+
flex-wrap: wrap;
|
|
3457
|
+
}
|
|
3458
|
+
.video-shortcuts-help .shortcut-item:last-child {
|
|
3459
|
+
margin-bottom: 0;
|
|
3460
|
+
}
|
|
3461
|
+
.video-shortcuts-help kbd {
|
|
3462
|
+
display: inline-block;
|
|
3463
|
+
background: rgba(255, 255, 255, 0.15);
|
|
3464
|
+
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
3465
|
+
border-radius: 4px;
|
|
3466
|
+
padding: 2px 6px;
|
|
3467
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
3468
|
+
font-size: 10px;
|
|
3469
|
+
font-weight: 500;
|
|
3470
|
+
min-width: 18px;
|
|
3471
|
+
text-align: center;
|
|
3472
|
+
margin-right: 2px;
|
|
3473
|
+
}
|
|
3096
3474
|
/* Reviw Questions Modal */
|
|
3097
3475
|
.reviw-questions-overlay {
|
|
3098
3476
|
display: none;
|
|
@@ -6105,18 +6483,175 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
|
|
|
6105
6483
|
|
|
6106
6484
|
const videoExtensions = /\\.(mp4|mov|webm|avi|mkv|m4v|ogv)$/i;
|
|
6107
6485
|
|
|
6108
|
-
// Collect all video
|
|
6109
|
-
|
|
6486
|
+
// Collect all video sources for navigation
|
|
6487
|
+
// Includes both: a[href=video] (link syntax) and video.video-preview (image syntax )
|
|
6488
|
+
const allVideoSources = [];
|
|
6489
|
+
|
|
6490
|
+
// 1. Collect video links (a tags with video href)
|
|
6491
|
+
const videoLinks = Array.from(preview.querySelectorAll('a')).filter(link => {
|
|
6110
6492
|
const href = link.getAttribute('href');
|
|
6111
6493
|
return href && videoExtensions.test(href);
|
|
6112
6494
|
});
|
|
6495
|
+
videoLinks.forEach(link => {
|
|
6496
|
+
allVideoSources.push({
|
|
6497
|
+
type: 'link',
|
|
6498
|
+
element: link,
|
|
6499
|
+
src: link.getAttribute('href')
|
|
6500
|
+
});
|
|
6501
|
+
});
|
|
6502
|
+
|
|
6503
|
+
// 2. Collect video elements (video tags with video-preview class, from  syntax)
|
|
6504
|
+
const videoElements = Array.from(preview.querySelectorAll('video.video-preview'));
|
|
6505
|
+
videoElements.forEach(video => {
|
|
6506
|
+
const src = video.getAttribute('src');
|
|
6507
|
+
if (src && videoExtensions.test(src)) {
|
|
6508
|
+
allVideoSources.push({
|
|
6509
|
+
type: 'video',
|
|
6510
|
+
element: video,
|
|
6511
|
+
src: src
|
|
6512
|
+
});
|
|
6513
|
+
}
|
|
6514
|
+
});
|
|
6515
|
+
|
|
6516
|
+
// For backwards compatibility
|
|
6517
|
+
const allVideoLinks = videoLinks;
|
|
6113
6518
|
let currentVideoIndex = -1;
|
|
6519
|
+
let currentTimelineEventSource = null;
|
|
6520
|
+
let timelineThumbnails = [];
|
|
6521
|
+
|
|
6522
|
+
// Format time as MM:SS
|
|
6523
|
+
function formatTime(seconds) {
|
|
6524
|
+
const mins = Math.floor(seconds / 60);
|
|
6525
|
+
const secs = Math.floor(seconds % 60);
|
|
6526
|
+
return mins + ':' + (secs < 10 ? '0' : '') + secs;
|
|
6527
|
+
}
|
|
6528
|
+
|
|
6529
|
+
// Load video timeline via SSE
|
|
6530
|
+
function loadVideoTimeline(videoPath, video) {
|
|
6531
|
+
// Close existing connection
|
|
6532
|
+
if (currentTimelineEventSource) {
|
|
6533
|
+
currentTimelineEventSource.close();
|
|
6534
|
+
currentTimelineEventSource = null;
|
|
6535
|
+
}
|
|
6536
|
+
|
|
6537
|
+
// Clear existing timeline
|
|
6538
|
+
const existingTimeline = videoContainer.querySelector('.video-timeline');
|
|
6539
|
+
if (existingTimeline) existingTimeline.remove();
|
|
6540
|
+
timelineThumbnails = [];
|
|
6541
|
+
|
|
6542
|
+
// Create timeline container
|
|
6543
|
+
const timeline = document.createElement('div');
|
|
6544
|
+
timeline.className = 'video-timeline';
|
|
6545
|
+
timeline.addEventListener('click', (e) => e.stopPropagation()); // Prevent closing overlay
|
|
6546
|
+
|
|
6547
|
+
// Add loading indicator
|
|
6548
|
+
const loading = document.createElement('div');
|
|
6549
|
+
loading.className = 'timeline-loading';
|
|
6550
|
+
loading.textContent = 'Loading timeline...';
|
|
6551
|
+
timeline.appendChild(loading);
|
|
6552
|
+
|
|
6553
|
+
videoContainer.appendChild(timeline);
|
|
6554
|
+
|
|
6555
|
+
// Start SSE connection
|
|
6556
|
+
const encodedPath = encodeURIComponent(videoPath);
|
|
6557
|
+
const es = new EventSource('/video-timeline?path=' + encodedPath);
|
|
6558
|
+
currentTimelineEventSource = es;
|
|
6559
|
+
|
|
6560
|
+
es.onmessage = function(e) {
|
|
6561
|
+
const data = JSON.parse(e.data);
|
|
6562
|
+
|
|
6563
|
+
if (data.type === 'thumbnail') {
|
|
6564
|
+
// Remove loading indicator on first thumbnail
|
|
6565
|
+
const loadingEl = timeline.querySelector('.timeline-loading');
|
|
6566
|
+
if (loadingEl) loadingEl.remove();
|
|
6567
|
+
|
|
6568
|
+
// Create thumbnail wrapper
|
|
6569
|
+
const wrapper = document.createElement('div');
|
|
6570
|
+
wrapper.className = 'timeline-thumb-wrapper';
|
|
6571
|
+
|
|
6572
|
+
// Create thumbnail image
|
|
6573
|
+
const thumb = document.createElement('img');
|
|
6574
|
+
thumb.className = 'timeline-thumb';
|
|
6575
|
+
thumb.src = data.data;
|
|
6576
|
+
thumb.dataset.time = data.time;
|
|
6577
|
+
thumb.title = 'Jump to ' + formatTime(data.time);
|
|
6578
|
+
|
|
6579
|
+
// Click to seek (no autoplay - user controls playback)
|
|
6580
|
+
thumb.addEventListener('click', function() {
|
|
6581
|
+
video.currentTime = parseFloat(thumb.dataset.time);
|
|
6582
|
+
// Don't call video.play() - let user control when to play
|
|
6583
|
+
});
|
|
6584
|
+
|
|
6585
|
+
// Add time label
|
|
6586
|
+
const timeLabel = document.createElement('span');
|
|
6587
|
+
timeLabel.className = 'timeline-time';
|
|
6588
|
+
timeLabel.textContent = formatTime(data.time);
|
|
6589
|
+
|
|
6590
|
+
wrapper.appendChild(thumb);
|
|
6591
|
+
wrapper.appendChild(timeLabel);
|
|
6592
|
+
timeline.appendChild(wrapper);
|
|
6593
|
+
|
|
6594
|
+
timelineThumbnails.push({ element: thumb, time: data.time });
|
|
6595
|
+
} else if (data.type === 'complete') {
|
|
6596
|
+
es.close();
|
|
6597
|
+
currentTimelineEventSource = null;
|
|
6598
|
+
|
|
6599
|
+
// If no thumbnails were added, show message
|
|
6600
|
+
if (timelineThumbnails.length === 0) {
|
|
6601
|
+
const loadingEl = timeline.querySelector('.timeline-loading');
|
|
6602
|
+
if (loadingEl) {
|
|
6603
|
+
loadingEl.textContent = 'No scene changes detected';
|
|
6604
|
+
}
|
|
6605
|
+
}
|
|
6606
|
+
} else if (data.type === 'error') {
|
|
6607
|
+
es.close();
|
|
6608
|
+
currentTimelineEventSource = null;
|
|
6609
|
+
// Remove timeline on error (ffmpeg not available, etc.)
|
|
6610
|
+
timeline.remove();
|
|
6611
|
+
}
|
|
6612
|
+
};
|
|
6613
|
+
|
|
6614
|
+
es.onerror = function() {
|
|
6615
|
+
es.close();
|
|
6616
|
+
currentTimelineEventSource = null;
|
|
6617
|
+
// Remove timeline on connection error
|
|
6618
|
+
timeline.remove();
|
|
6619
|
+
};
|
|
6620
|
+
|
|
6621
|
+
// Update active thumbnail on video timeupdate
|
|
6622
|
+
video.addEventListener('timeupdate', function() {
|
|
6623
|
+
if (timelineThumbnails.length === 0) return;
|
|
6624
|
+
|
|
6625
|
+
const currentTime = video.currentTime;
|
|
6626
|
+
let closestIdx = 0;
|
|
6627
|
+
let closestDiff = Infinity;
|
|
6628
|
+
|
|
6629
|
+
for (let i = 0; i < timelineThumbnails.length; i++) {
|
|
6630
|
+
const diff = Math.abs(timelineThumbnails[i].time - currentTime);
|
|
6631
|
+
if (diff < closestDiff) {
|
|
6632
|
+
closestDiff = diff;
|
|
6633
|
+
closestIdx = i;
|
|
6634
|
+
}
|
|
6635
|
+
}
|
|
6636
|
+
|
|
6637
|
+
// Update active class
|
|
6638
|
+
timelineThumbnails.forEach(function(t, idx) {
|
|
6639
|
+
if (idx === closestIdx) {
|
|
6640
|
+
t.element.classList.add('active');
|
|
6641
|
+
// Scroll to active thumbnail
|
|
6642
|
+
t.element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
|
6643
|
+
} else {
|
|
6644
|
+
t.element.classList.remove('active');
|
|
6645
|
+
}
|
|
6646
|
+
});
|
|
6647
|
+
});
|
|
6648
|
+
}
|
|
6114
6649
|
|
|
6115
6650
|
function showVideo(index) {
|
|
6116
|
-
if (index < 0 || index >=
|
|
6651
|
+
if (index < 0 || index >= allVideoSources.length) return;
|
|
6117
6652
|
currentVideoIndex = index;
|
|
6118
|
-
const
|
|
6119
|
-
const href =
|
|
6653
|
+
const source = allVideoSources[index];
|
|
6654
|
+
const href = source.src;
|
|
6120
6655
|
|
|
6121
6656
|
// Remove existing video if any
|
|
6122
6657
|
const existingVideo = videoContainer.querySelector('video');
|
|
@@ -6130,31 +6665,80 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
|
|
|
6130
6665
|
const existingCounter = videoContainer.querySelector('.fullscreen-counter');
|
|
6131
6666
|
if (existingCounter) existingCounter.remove();
|
|
6132
6667
|
|
|
6668
|
+
// Remove existing timeline
|
|
6669
|
+
const existingTimeline = videoContainer.querySelector('.video-timeline');
|
|
6670
|
+
if (existingTimeline) existingTimeline.remove();
|
|
6671
|
+
|
|
6672
|
+
// Remove existing shortcuts help
|
|
6673
|
+
const existingHelp = videoOverlay.querySelector('.video-shortcuts-help');
|
|
6674
|
+
if (existingHelp) existingHelp.remove();
|
|
6675
|
+
|
|
6133
6676
|
const video = document.createElement('video');
|
|
6134
6677
|
video.src = href;
|
|
6135
6678
|
video.controls = true;
|
|
6136
|
-
|
|
6137
|
-
video.style.
|
|
6138
|
-
video.style.
|
|
6679
|
+
// autoplay disabled: サムネイルクリック時はシークのみ、再生は手動
|
|
6680
|
+
video.style.width = '100%';
|
|
6681
|
+
video.style.height = 'calc(100% - 80px)'; // Leave room for timeline
|
|
6682
|
+
video.style.objectFit = 'contain';
|
|
6139
6683
|
// Prevent click on video from closing overlay
|
|
6140
6684
|
video.addEventListener('click', (e) => e.stopPropagation());
|
|
6141
6685
|
videoContainer.appendChild(video);
|
|
6142
6686
|
|
|
6143
6687
|
// Show navigation hint
|
|
6144
|
-
if (
|
|
6688
|
+
if (allVideoSources.length > 1) {
|
|
6145
6689
|
const counter = document.createElement('div');
|
|
6146
6690
|
counter.className = 'fullscreen-counter';
|
|
6147
|
-
counter.textContent = \`\${index + 1} / \${
|
|
6148
|
-
counter.style.cssText = 'position:absolute;bottom:
|
|
6691
|
+
counter.textContent = \`\${index + 1} / \${allVideoSources.length}\`;
|
|
6692
|
+
counter.style.cssText = 'position:absolute;bottom:100px;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.6);padding:8px 16px;border-radius:20px;font-size:14px;z-index:10;';
|
|
6149
6693
|
videoContainer.appendChild(counter);
|
|
6150
6694
|
}
|
|
6151
6695
|
|
|
6696
|
+
// Add keyboard shortcuts help
|
|
6697
|
+
const shortcutsHelp = document.createElement('div');
|
|
6698
|
+
shortcutsHelp.className = 'video-shortcuts-help';
|
|
6699
|
+
shortcutsHelp.innerHTML = \`
|
|
6700
|
+
<div class="shortcuts-title">Shortcuts</div>
|
|
6701
|
+
<div class="shortcut-item"><kbd>Space</kbd><kbd>K</kbd> Play/Pause</div>
|
|
6702
|
+
<div class="shortcut-item"><kbd>\u2190</kbd><kbd>J</kbd> Prev scene</div>
|
|
6703
|
+
<div class="shortcut-item"><kbd>\u2192</kbd><kbd>L</kbd> Next scene</div>
|
|
6704
|
+
<div class="shortcut-item"><kbd>ESC</kbd> Close</div>
|
|
6705
|
+
\`;
|
|
6706
|
+
shortcutsHelp.style.cssText = \`
|
|
6707
|
+
position: absolute;
|
|
6708
|
+
left: 20px;
|
|
6709
|
+
top: 50%;
|
|
6710
|
+
transform: translateY(-50%);
|
|
6711
|
+
background: rgba(0, 0, 0, 0.6);
|
|
6712
|
+
color: rgba(255, 255, 255, 0.9);
|
|
6713
|
+
padding: 16px;
|
|
6714
|
+
border-radius: 8px;
|
|
6715
|
+
font-size: 12px;
|
|
6716
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
6717
|
+
z-index: 10;
|
|
6718
|
+
pointer-events: none;
|
|
6719
|
+
user-select: none;
|
|
6720
|
+
\`;
|
|
6721
|
+
// Prevent click propagation
|
|
6722
|
+
shortcutsHelp.addEventListener('click', (e) => e.stopPropagation());
|
|
6723
|
+
videoOverlay.appendChild(shortcutsHelp);
|
|
6724
|
+
|
|
6152
6725
|
videoOverlay.classList.add('visible');
|
|
6726
|
+
|
|
6727
|
+
// Load video timeline
|
|
6728
|
+
loadVideoTimeline(href, video);
|
|
6153
6729
|
}
|
|
6154
6730
|
|
|
6155
6731
|
function closeVideoOverlay() {
|
|
6156
6732
|
videoOverlay.classList.remove('visible');
|
|
6157
6733
|
currentVideoIndex = -1;
|
|
6734
|
+
|
|
6735
|
+
// Close timeline SSE connection
|
|
6736
|
+
if (currentTimelineEventSource) {
|
|
6737
|
+
currentTimelineEventSource.close();
|
|
6738
|
+
currentTimelineEventSource = null;
|
|
6739
|
+
}
|
|
6740
|
+
timelineThumbnails = [];
|
|
6741
|
+
|
|
6158
6742
|
// Stop and remove video
|
|
6159
6743
|
const video = videoContainer.querySelector('video');
|
|
6160
6744
|
if (video) {
|
|
@@ -6162,16 +6746,65 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
|
|
|
6162
6746
|
video.src = '';
|
|
6163
6747
|
video.remove();
|
|
6164
6748
|
}
|
|
6749
|
+
|
|
6750
|
+
// Remove timeline
|
|
6751
|
+
const timeline = videoContainer.querySelector('.video-timeline');
|
|
6752
|
+
if (timeline) timeline.remove();
|
|
6753
|
+
|
|
6754
|
+
// Remove shortcuts help
|
|
6755
|
+
const shortcutsHelp = videoOverlay.querySelector('.video-shortcuts-help');
|
|
6756
|
+
if (shortcutsHelp) shortcutsHelp.remove();
|
|
6165
6757
|
}
|
|
6166
6758
|
|
|
6167
6759
|
function navigateVideo(direction) {
|
|
6168
6760
|
if (!videoOverlay.classList.contains('visible')) return;
|
|
6169
6761
|
const newIndex = currentVideoIndex + direction;
|
|
6170
|
-
if (newIndex >= 0 && newIndex <
|
|
6762
|
+
if (newIndex >= 0 && newIndex < allVideoSources.length) {
|
|
6171
6763
|
showVideo(newIndex);
|
|
6172
6764
|
}
|
|
6173
6765
|
}
|
|
6174
6766
|
|
|
6767
|
+
// Navigate between thumbnails (scenes) within the video
|
|
6768
|
+
function navigateThumbnail(direction) {
|
|
6769
|
+
if (!videoOverlay.classList.contains('visible')) return;
|
|
6770
|
+
if (timelineThumbnails.length === 0) return;
|
|
6771
|
+
|
|
6772
|
+
const video = videoContainer.querySelector('video');
|
|
6773
|
+
if (!video) return;
|
|
6774
|
+
|
|
6775
|
+
const currentTime = video.currentTime;
|
|
6776
|
+
|
|
6777
|
+
// Find current thumbnail index
|
|
6778
|
+
let currentThumbIdx = 0;
|
|
6779
|
+
let closestDiff = Infinity;
|
|
6780
|
+
for (let i = 0; i < timelineThumbnails.length; i++) {
|
|
6781
|
+
const diff = Math.abs(timelineThumbnails[i].time - currentTime);
|
|
6782
|
+
if (diff < closestDiff) {
|
|
6783
|
+
closestDiff = diff;
|
|
6784
|
+
currentThumbIdx = i;
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
|
|
6788
|
+
// Calculate target index
|
|
6789
|
+
const targetIdx = currentThumbIdx + direction;
|
|
6790
|
+
if (targetIdx >= 0 && targetIdx < timelineThumbnails.length) {
|
|
6791
|
+
video.currentTime = timelineThumbnails[targetIdx].time;
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6794
|
+
|
|
6795
|
+
// Toggle video play/pause
|
|
6796
|
+
function toggleVideoPlayPause() {
|
|
6797
|
+
if (!videoOverlay.classList.contains('visible')) return;
|
|
6798
|
+
const video = videoContainer.querySelector('video');
|
|
6799
|
+
if (!video) return;
|
|
6800
|
+
|
|
6801
|
+
if (video.paused) {
|
|
6802
|
+
video.play();
|
|
6803
|
+
} else {
|
|
6804
|
+
video.pause();
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
|
|
6175
6808
|
if (videoClose) {
|
|
6176
6809
|
videoClose.addEventListener('click', closeVideoOverlay);
|
|
6177
6810
|
}
|
|
@@ -6190,31 +6823,113 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
|
|
|
6190
6823
|
case 'Escape':
|
|
6191
6824
|
closeVideoOverlay();
|
|
6192
6825
|
break;
|
|
6826
|
+
// Scene navigation: Arrow keys and j/l (YouTube-like)
|
|
6193
6827
|
case 'ArrowLeft':
|
|
6828
|
+
case 'j':
|
|
6829
|
+
e.preventDefault();
|
|
6830
|
+
navigateThumbnail(-1);
|
|
6831
|
+
break;
|
|
6832
|
+
case 'ArrowRight':
|
|
6833
|
+
case 'l':
|
|
6834
|
+
e.preventDefault();
|
|
6835
|
+
navigateThumbnail(1);
|
|
6836
|
+
break;
|
|
6837
|
+
// Video navigation: Arrow Up/Down for switching between videos
|
|
6194
6838
|
case 'ArrowUp':
|
|
6195
6839
|
e.preventDefault();
|
|
6196
6840
|
navigateVideo(-1);
|
|
6197
6841
|
break;
|
|
6198
|
-
case 'ArrowRight':
|
|
6199
6842
|
case 'ArrowDown':
|
|
6200
6843
|
e.preventDefault();
|
|
6201
6844
|
navigateVideo(1);
|
|
6202
6845
|
break;
|
|
6846
|
+
// Play/Pause toggle: Space and k (YouTube-like)
|
|
6847
|
+
case ' ':
|
|
6848
|
+
case 'k':
|
|
6849
|
+
e.preventDefault();
|
|
6850
|
+
toggleVideoPlayPause();
|
|
6851
|
+
break;
|
|
6203
6852
|
}
|
|
6204
6853
|
});
|
|
6205
6854
|
|
|
6206
|
-
// Intercept video
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
link.title = allVideoLinks.length > 1
|
|
6210
|
-
? 'Click to play video fullscreen (← → to navigate)'
|
|
6211
|
-
: 'Click to play video fullscreen';
|
|
6855
|
+
// Intercept video source clicks
|
|
6856
|
+
allVideoSources.forEach((source, index) => {
|
|
6857
|
+
const element = source.element;
|
|
6212
6858
|
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6859
|
+
if (source.type === 'link') {
|
|
6860
|
+
// For link syntax: single click opens fullscreen
|
|
6861
|
+
element.style.cursor = 'pointer';
|
|
6862
|
+
element.title = allVideoSources.length > 1
|
|
6863
|
+
? 'Click to play video fullscreen (\u2190 \u2192 to navigate)'
|
|
6864
|
+
: 'Click to play video fullscreen';
|
|
6865
|
+
|
|
6866
|
+
element.addEventListener('click', (e) => {
|
|
6867
|
+
e.preventDefault();
|
|
6868
|
+
showVideo(index);
|
|
6869
|
+
});
|
|
6870
|
+
} else if (source.type === 'video') {
|
|
6871
|
+
// For image syntax : add fullscreen overlay button
|
|
6872
|
+
// Wrap video in a container with fullscreen button
|
|
6873
|
+
const wrapper = document.createElement('div');
|
|
6874
|
+
wrapper.className = 'video-fullscreen-wrapper';
|
|
6875
|
+
wrapper.style.cssText = 'position:relative;display:inline-block;';
|
|
6876
|
+
|
|
6877
|
+
// Insert wrapper before video, then move video inside
|
|
6878
|
+
element.parentNode.insertBefore(wrapper, element);
|
|
6879
|
+
wrapper.appendChild(element);
|
|
6880
|
+
|
|
6881
|
+
// Create fullscreen button overlay
|
|
6882
|
+
const fsButton = document.createElement('button');
|
|
6883
|
+
fsButton.className = 'video-fullscreen-btn';
|
|
6884
|
+
fsButton.innerHTML = '<span style="font-size:14px;margin-right:4px">\u26F6</span>Fullscreen+';
|
|
6885
|
+
fsButton.title = 'Enhanced fullscreen with keyboard shortcuts (Space=Play/Pause, \u2190\u2192=Scene navigation)';
|
|
6886
|
+
fsButton.style.cssText = \`
|
|
6887
|
+
position: absolute;
|
|
6888
|
+
top: 8px;
|
|
6889
|
+
right: 8px;
|
|
6890
|
+
padding: 6px 12px;
|
|
6891
|
+
background: rgba(59, 130, 246, 0.9);
|
|
6892
|
+
color: white;
|
|
6893
|
+
border: none;
|
|
6894
|
+
border-radius: 6px;
|
|
6895
|
+
cursor: pointer;
|
|
6896
|
+
font-size: 12px;
|
|
6897
|
+
font-weight: 500;
|
|
6898
|
+
display: flex;
|
|
6899
|
+
align-items: center;
|
|
6900
|
+
justify-content: center;
|
|
6901
|
+
opacity: 0.8;
|
|
6902
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
6903
|
+
z-index: 10;
|
|
6904
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
6905
|
+
\`;
|
|
6906
|
+
|
|
6907
|
+
wrapper.appendChild(fsButton);
|
|
6908
|
+
|
|
6909
|
+
// Highlight button on hover
|
|
6910
|
+
fsButton.addEventListener('mouseenter', () => {
|
|
6911
|
+
fsButton.style.opacity = '1';
|
|
6912
|
+
fsButton.style.transform = 'scale(1.05)';
|
|
6913
|
+
});
|
|
6914
|
+
fsButton.addEventListener('mouseleave', () => {
|
|
6915
|
+
fsButton.style.opacity = '0.8';
|
|
6916
|
+
fsButton.style.transform = 'scale(1)';
|
|
6917
|
+
});
|
|
6918
|
+
|
|
6919
|
+
// Click button to open fullscreen
|
|
6920
|
+
fsButton.addEventListener('click', (e) => {
|
|
6921
|
+
e.preventDefault();
|
|
6922
|
+
e.stopPropagation();
|
|
6923
|
+
showVideo(index);
|
|
6924
|
+
});
|
|
6925
|
+
|
|
6926
|
+
// Also support double-click on video itself
|
|
6927
|
+
element.addEventListener('dblclick', (e) => {
|
|
6928
|
+
e.preventDefault();
|
|
6929
|
+
e.stopPropagation();
|
|
6930
|
+
showVideo(index);
|
|
6931
|
+
});
|
|
6932
|
+
}
|
|
6218
6933
|
});
|
|
6219
6934
|
})();
|
|
6220
6935
|
|
|
@@ -7215,10 +7930,24 @@ function shutdownAll() {
|
|
|
7215
7930
|
});
|
|
7216
7931
|
if (ctx.server) ctx.server.close();
|
|
7217
7932
|
}
|
|
7933
|
+
// Cleanup timeline temp directories
|
|
7934
|
+
cleanupTimelineTempDirs();
|
|
7218
7935
|
outputAllResults();
|
|
7219
7936
|
setTimeout(() => process.exit(0), 500).unref();
|
|
7220
7937
|
}
|
|
7221
7938
|
|
|
7939
|
+
// Cleanup all timeline temporary directories
|
|
7940
|
+
function cleanupTimelineTempDirs() {
|
|
7941
|
+
for (const dir of timelineTempDirs) {
|
|
7942
|
+
try {
|
|
7943
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
7944
|
+
} catch (err) {
|
|
7945
|
+
// Ignore cleanup errors
|
|
7946
|
+
}
|
|
7947
|
+
}
|
|
7948
|
+
timelineTempDirs.clear();
|
|
7949
|
+
}
|
|
7950
|
+
|
|
7222
7951
|
process.on("SIGINT", shutdownAll);
|
|
7223
7952
|
process.on("SIGTERM", shutdownAll);
|
|
7224
7953
|
|
|
@@ -7357,6 +8086,72 @@ function createFileServer(filePath, fileIndex = 0) {
|
|
|
7357
8086
|
return;
|
|
7358
8087
|
}
|
|
7359
8088
|
|
|
8089
|
+
// Video timeline SSE endpoint - streams thumbnail data for video scrubbing
|
|
8090
|
+
if (req.method === "GET" && req.url.startsWith("/video-timeline?")) {
|
|
8091
|
+
const urlParams = new URL(req.url, `http://localhost`);
|
|
8092
|
+
const videoPath = urlParams.searchParams.get("path");
|
|
8093
|
+
|
|
8094
|
+
if (!videoPath) {
|
|
8095
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
8096
|
+
res.end("missing path parameter");
|
|
8097
|
+
return;
|
|
8098
|
+
}
|
|
8099
|
+
|
|
8100
|
+
// Security check: prevent path traversal
|
|
8101
|
+
if (videoPath.includes("..")) {
|
|
8102
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
8103
|
+
res.end("forbidden");
|
|
8104
|
+
return;
|
|
8105
|
+
}
|
|
8106
|
+
|
|
8107
|
+
// Check ffmpeg availability
|
|
8108
|
+
if (!checkFfmpegAvailable()) {
|
|
8109
|
+
res.writeHead(200, {
|
|
8110
|
+
"Content-Type": "text/event-stream",
|
|
8111
|
+
"Cache-Control": "no-cache",
|
|
8112
|
+
Connection: "keep-alive",
|
|
8113
|
+
});
|
|
8114
|
+
res.write(`data: ${JSON.stringify({ type: "error", message: "ffmpeg not available" })}\n\n`);
|
|
8115
|
+
res.end();
|
|
8116
|
+
return;
|
|
8117
|
+
}
|
|
8118
|
+
|
|
8119
|
+
// Resolve full video path
|
|
8120
|
+
const fullVideoPath = path.join(baseDir, videoPath);
|
|
8121
|
+
if (!fs.existsSync(fullVideoPath)) {
|
|
8122
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
8123
|
+
res.end("video not found");
|
|
8124
|
+
return;
|
|
8125
|
+
}
|
|
8126
|
+
|
|
8127
|
+
// Setup SSE response
|
|
8128
|
+
res.writeHead(200, {
|
|
8129
|
+
"Content-Type": "text/event-stream",
|
|
8130
|
+
"Cache-Control": "no-cache",
|
|
8131
|
+
Connection: "keep-alive",
|
|
8132
|
+
"X-Accel-Buffering": "no",
|
|
8133
|
+
});
|
|
8134
|
+
|
|
8135
|
+
// Create temp directory for thumbnails
|
|
8136
|
+
const tmpDir = path.join(os.tmpdir(), `reviw-timeline-${crypto.randomBytes(8).toString('hex')}`);
|
|
8137
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
8138
|
+
timelineTempDirs.add(tmpDir);
|
|
8139
|
+
|
|
8140
|
+
// Extract thumbnails using ffmpeg with scene detection
|
|
8141
|
+
extractVideoTimeline(fullVideoPath, tmpDir, res, () => {
|
|
8142
|
+
// Cleanup after SSE closes
|
|
8143
|
+
req.on("close", () => {
|
|
8144
|
+
try {
|
|
8145
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
8146
|
+
timelineTempDirs.delete(tmpDir);
|
|
8147
|
+
} catch (err) {
|
|
8148
|
+
// Ignore cleanup errors
|
|
8149
|
+
}
|
|
8150
|
+
});
|
|
8151
|
+
});
|
|
8152
|
+
return;
|
|
8153
|
+
}
|
|
8154
|
+
|
|
7360
8155
|
// Static file serving for images and other assets
|
|
7361
8156
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
7362
8157
|
const MIME_TYPES = {
|
|
@@ -7425,6 +8220,13 @@ function createFileServer(filePath, fileIndex = 0) {
|
|
|
7425
8220
|
res.end();
|
|
7426
8221
|
} else {
|
|
7427
8222
|
const stream = fs.createReadStream(staticPath, { start, end });
|
|
8223
|
+
stream.on("error", (streamErr) => {
|
|
8224
|
+
console.error("Range stream error:", streamErr);
|
|
8225
|
+
if (!res.headersSent) {
|
|
8226
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
8227
|
+
}
|
|
8228
|
+
res.end();
|
|
8229
|
+
});
|
|
7428
8230
|
stream.pipe(res);
|
|
7429
8231
|
}
|
|
7430
8232
|
} else {
|
|
@@ -7445,6 +8247,13 @@ function createFileServer(filePath, fileIndex = 0) {
|
|
|
7445
8247
|
} else if (fileSize > 1024 * 1024) {
|
|
7446
8248
|
// Use streaming for large files (> 1MB)
|
|
7447
8249
|
const stream = fs.createReadStream(staticPath);
|
|
8250
|
+
stream.on("error", (streamErr) => {
|
|
8251
|
+
console.error("Stream error:", streamErr);
|
|
8252
|
+
if (!res.headersSent) {
|
|
8253
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
8254
|
+
}
|
|
8255
|
+
res.end();
|
|
8256
|
+
});
|
|
7448
8257
|
stream.pipe(res);
|
|
7449
8258
|
} else {
|
|
7450
8259
|
const content = fs.readFileSync(staticPath);
|
|
@@ -7454,12 +8263,15 @@ function createFileServer(filePath, fileIndex = 0) {
|
|
|
7454
8263
|
return;
|
|
7455
8264
|
}
|
|
7456
8265
|
} catch (err) {
|
|
7457
|
-
|
|
8266
|
+
console.error("Static file error:", err);
|
|
8267
|
+
// fall through to 404 (but check headersSent first)
|
|
7458
8268
|
}
|
|
7459
8269
|
}
|
|
7460
8270
|
|
|
7461
|
-
res.
|
|
7462
|
-
|
|
8271
|
+
if (!res.headersSent) {
|
|
8272
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
8273
|
+
res.end("not found");
|
|
8274
|
+
}
|
|
7463
8275
|
});
|
|
7464
8276
|
|
|
7465
8277
|
let serverStarted = false;
|