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.
Files changed (2) hide show
  1. package/cli.cjs +841 -29
  2. 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
- max-width: 100%;
3092
- max-height: 100%;
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 links for navigation
6109
- const allVideoLinks = Array.from(preview.querySelectorAll('a')).filter(link => {
6486
+ // Collect all video sources for navigation
6487
+ // Includes both: a[href=video] (link syntax) and video.video-preview (image syntax ![](video.mp4))
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 ![](video.mp4) 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 >= allVideoLinks.length) return;
6651
+ if (index < 0 || index >= allVideoSources.length) return;
6117
6652
  currentVideoIndex = index;
6118
- const link = allVideoLinks[index];
6119
- const href = link.getAttribute('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
- video.autoplay = true;
6137
- video.style.maxWidth = '100%';
6138
- video.style.maxHeight = '100%';
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 (allVideoLinks.length > 1) {
6688
+ if (allVideoSources.length > 1) {
6145
6689
  const counter = document.createElement('div');
6146
6690
  counter.className = 'fullscreen-counter';
6147
- counter.textContent = \`\${index + 1} / \${allVideoLinks.length}\`;
6148
- counter.style.cssText = 'position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.6);padding:8px 16px;border-radius:20px;font-size:14px;';
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 < allVideoLinks.length) {
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 link clicks
6207
- allVideoLinks.forEach((link, index) => {
6208
- link.style.cursor = 'pointer';
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
- link.addEventListener('click', (e) => {
6214
- e.preventDefault();
6215
- // Don't stop propagation - allow select to work
6216
- showVideo(index);
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 ![](video.mp4): 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
- // fall through to 404
8266
+ console.error("Static file error:", err);
8267
+ // fall through to 404 (but check headersSent first)
7458
8268
  }
7459
8269
  }
7460
8270
 
7461
- res.writeHead(404, { "Content-Type": "text/plain" });
7462
- res.end("not found");
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.17.4",
3
+ "version": "0.18.0",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {