@doedja/scenecut 1.0.1 → 1.0.2

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/README.md CHANGED
@@ -4,10 +4,15 @@ Fast, accurate scene change detection for Node.js using Xvid's motion estimation
4
4
 
5
5
  ## Features
6
6
 
7
- - **Fast**: WebAssembly-accelerated motion estimation (35-45 fps on typical hardware)
8
- - **Accurate**: Uses Xvid's proven motion estimation algorithm from vapoursynth-wwxd
9
- - **Multiple output formats**: Aegisub keyframes, timecodes, CSV, JSON
10
- - **Easy to use**: Simple CLI and programmatic API
7
+ - **Fast**: WebAssembly-accelerated motion estimation with SIMD, double-buffered frame pipeline, and quick-reject filtering
8
+ - **Accurate**: Uses Xvid's proven motion estimation algorithm with configurable thresholds that actually work
9
+ - **Confidence scoring**: Each scene change includes a 0-1 confidence score from the motion estimator
10
+ - **Fade/dissolve detection**: Catches gradual transitions that per-frame analysis misses
11
+ - **Temporal smoothing**: Optional sliding window filter to suppress false positives and flash frames
12
+ - **Multiple output formats**: Aegisub keyframes, timecodes, CSV (with confidence + duration), JSON
13
+ - **Scene duration**: Each scene includes duration and frame count
14
+ - **Cancellable**: AbortController support with `--timeout` for long videos
15
+ - **Batch thumbnails**: Extract scene images in a single FFmpeg pass
11
16
  - **Cross-platform**: Works on Windows, Linux, and macOS
12
17
 
13
18
  ## Installation
@@ -30,7 +35,6 @@ npm install @doedja/scenecut
30
35
 
31
36
  ```bash
32
37
  # Simple - detects scenes and saves to Aegisub format (default)
33
- # Creates input_keyframes.txt
34
38
  scenecut "input.mkv"
35
39
 
36
40
  # Specify custom output filename
@@ -39,11 +43,20 @@ scenecut "video.mkv" -o keyframes.txt
39
43
  # For timecode output
40
44
  scenecut "video.mp4" --format timecode -o timecodes.txt
41
45
 
42
- # JSON format with full metadata
46
+ # JSON format with full metadata, confidence, and duration
43
47
  scenecut "movie.avi" --format json
44
48
 
45
49
  # CSV format for spreadsheet analysis
46
50
  scenecut "movie.avi" --format csv -o scenes.csv
51
+
52
+ # High sensitivity for subtle scene changes
53
+ scenecut "anime.mkv" --sensitivity high --verbose
54
+
55
+ # Abort after 2 minutes
56
+ scenecut "long-movie.mkv" --timeout 120
57
+
58
+ # Extract scene thumbnails
59
+ scenecut "video.mp4" --thumbnails ./thumbs
47
60
  ```
48
61
 
49
62
  ### CLI Options
@@ -52,26 +65,20 @@ scenecut "movie.avi" --format csv -o scenes.csv
52
65
  |--------|-------|-------------|---------|
53
66
  | `--format` | `-f` | Output format: `aegisub`, `json`, `csv`, `timecode` | `aegisub` |
54
67
  | `--output` | `-o` | Output file path | `{filename}_keyframes.txt` |
55
- | `--sensitivity` | `-s` | Detection sensitivity: `low`, `medium`, `high` | `medium` |
68
+ | `--sensitivity` | `-s` | Detection sensitivity: `low`, `medium`, `high` | `low` |
69
+ | `--timeout` | `-t` | Abort after N seconds | no timeout |
70
+ | `--thumbnails` | | Extract scene thumbnails to directory | - |
56
71
  | `--quiet` | `-q` | Suppress progress output | `false` |
57
- | `--verbose` | `-v` | Show detailed output including each scene | `false` |
72
+ | `--verbose` | `-v` | Show detailed output including confidence | `false` |
58
73
  | `--help` | `-h` | Show help message | - |
59
74
 
60
- ### Examples
61
-
62
- ```bash
63
- # Default - creates anime_keyframes.txt in Aegisub format
64
- scenecut "anime.mkv"
65
-
66
- # High sensitivity for subtle scene changes
67
- scenecut "anime.mkv" --sensitivity high
68
-
69
- # JSON format with full metadata
70
- scenecut "video.mp4" --format json -o results.json
75
+ ### Sensitivity Levels
71
76
 
72
- # Verbose mode with detailed scene information
73
- scenecut "movie.mkv" --verbose
74
- ```
77
+ | Level | IntraThresh | IntraThresh2 | Use case |
78
+ |-------|------------|-------------|----------|
79
+ | `low` | 3000 | 150 | Fewer detections, only hard cuts (default) |
80
+ | `medium` | 2000 | 90 | Balanced |
81
+ | `high` | 1000 | 50 | More detections, catches subtle transitions |
75
82
 
76
83
  ## Output Formats
77
84
 
@@ -89,33 +96,23 @@ fps 23.976
89
96
 
90
97
  **Aegisub Workflow:**
91
98
  1. Generate keyframes: `scenecut "video.mkv" -f aegisub -o keyframes.txt`
92
- 2. In Aegisub: **Video** **Open Keyframes** Select `keyframes.txt`
99
+ 2. In Aegisub: **Video** > **Open Keyframes** > Select `keyframes.txt`
93
100
  3. Keyframes appear as visual markers on the timeline for precise subtitle timing
94
101
 
95
- ### Timecode Format (`.txt`)
96
-
97
- Simple timecode list (HH:MM:SS.mmm):
98
-
99
- ```
100
- 00:00:00.000
101
- 00:00:05.964
102
- 00:00:11.970
103
- ```
104
-
105
102
  ### CSV Format (`.csv`)
106
103
 
107
- Spreadsheet-compatible format:
104
+ Includes confidence scores and scene durations:
108
105
 
109
106
  ```csv
110
- frame,timestamp,timecode
111
- 0,0.0,00:00:00.000
112
- 143,5.964,00:00:05.964
113
- 287,11.970,00:00:11.970
107
+ frame,timestamp,timecode,confidence,duration,frameCount
108
+ 0,0.0,00:00:00.000,1.0000,5.964,143
109
+ 143,5.964,00:00:05.964,0.7234,6.006,144
110
+ 287,11.970,00:00:11.970,0.8912,4.171,100
114
111
  ```
115
112
 
116
113
  ### JSON Format (`.json`)
117
114
 
118
- Complete metadata and scene information:
115
+ Complete metadata with confidence, duration, and codec info:
119
116
 
120
117
  ```json
121
118
  {
@@ -123,26 +120,46 @@ Complete metadata and scene information:
123
120
  {
124
121
  "frameNumber": 0,
125
122
  "timestamp": 0.0,
126
- "timecode": "00:00:00.000"
123
+ "timecode": "00:00:00.000",
124
+ "confidence": 1.0,
125
+ "duration": 5.964,
126
+ "frameCount": 143
127
127
  },
128
128
  {
129
129
  "frameNumber": 143,
130
130
  "timestamp": 5.964,
131
- "timecode": "00:00:05.964"
131
+ "timecode": "00:00:05.964",
132
+ "confidence": 0.7234,
133
+ "duration": 6.006,
134
+ "frameCount": 144
132
135
  }
133
136
  ],
134
137
  "metadata": {
135
138
  "totalFrames": 3000,
136
139
  "duration": 125.08,
137
140
  "fps": 23.976,
138
- "resolution": {
139
- "width": 1920,
140
- "height": 1080
141
- }
141
+ "resolution": { "width": 1920, "height": 1080 },
142
+ "codec": "h264",
143
+ "pixelFormat": "yuv420p",
144
+ "bitrate": 5000000
145
+ },
146
+ "stats": {
147
+ "processingTime": 28.5,
148
+ "framesPerSecond": 105.2
142
149
  }
143
150
  }
144
151
  ```
145
152
 
153
+ ### Timecode Format (`.txt`)
154
+
155
+ Simple timecode list (HH:MM:SS.mmm):
156
+
157
+ ```
158
+ 00:00:00.000
159
+ 00:00:05.964
160
+ 00:00:11.970
161
+ ```
162
+
146
163
  ## Programmatic API
147
164
 
148
165
  ### Basic Usage
@@ -150,43 +167,62 @@ Complete metadata and scene information:
150
167
  ```javascript
151
168
  const { detectSceneChanges } = require('@doedja/scenecut');
152
169
 
153
- (async () => {
154
- const results = await detectSceneChanges('input.mp4');
170
+ const results = await detectSceneChanges('input.mp4');
155
171
 
156
- console.log(`Found ${results.scenes.length} scenes`);
157
- results.scenes.forEach(scene => {
158
- console.log(`Scene at frame ${scene.frameNumber} (${scene.timecode})`);
159
- });
160
- })();
172
+ console.log(`Found ${results.scenes.length} scenes`);
173
+ results.scenes.forEach(scene => {
174
+ console.log(`Scene at ${scene.timecode} (confidence: ${(scene.confidence * 100).toFixed(0)}%, duration: ${scene.duration?.toFixed(1)}s)`);
175
+ });
161
176
  ```
162
177
 
163
- ### Advanced Usage with Options
178
+ ### Advanced Usage
164
179
 
165
180
  ```javascript
166
181
  const { detectSceneChanges } = require('@doedja/scenecut');
167
182
 
183
+ const controller = new AbortController();
184
+
185
+ // Auto-cancel after 60 seconds
186
+ setTimeout(() => controller.abort(), 60000);
187
+
168
188
  const results = await detectSceneChanges('input.mp4', {
169
- sensitivity: 'high', // 'low' | 'medium' | 'high'
170
- searchRange: 'medium', // Motion search range
189
+ sensitivity: 'high',
190
+ searchRange: 'medium',
191
+ signal: controller.signal,
192
+
193
+ // Temporal smoothing to reduce false positives
194
+ temporalSmoothing: {
195
+ enabled: true,
196
+ windowSize: 5,
197
+ minConsecutive: 2
198
+ },
171
199
 
172
- // Progress callback
173
200
  onProgress: (progress) => {
174
- console.log(`Progress: ${progress.percent}%`);
175
- console.log(`Frame: ${progress.currentFrame}/${progress.totalFrames}`);
176
- console.log(`FPS: ${progress.fps}, ETA: ${progress.eta}s`);
201
+ console.log(`${progress.percent}% | ${progress.fps?.toFixed(1)} fps | ETA: ${progress.eta?.toFixed(0)}s | ${progress.scenesDetected} scenes`);
177
202
  },
178
203
 
179
- // Scene detection callback
180
204
  onScene: (scene) => {
181
- console.log(`Scene detected at frame ${scene.frameNumber}`);
182
- console.log(`Timecode: ${scene.timecode}`);
205
+ console.log(`Scene at frame ${scene.frameNumber} (${scene.timecode}) confidence: ${scene.confidence?.toFixed(2)}`);
183
206
  }
184
207
  });
185
208
 
186
- console.log('Detection complete!');
187
209
  console.log(`Total scenes: ${results.scenes.length}`);
188
- console.log(`Video duration: ${results.metadata.duration}s`);
189
- console.log(`Resolution: ${results.metadata.resolution.width}x${results.metadata.resolution.height}`);
210
+ console.log(`Video: ${results.metadata.codec} ${results.metadata.resolution.width}x${results.metadata.resolution.height}`);
211
+ ```
212
+
213
+ ### Extract Scene Thumbnails
214
+
215
+ ```javascript
216
+ const { extractSceneImages } = require('@doedja/scenecut');
217
+
218
+ const results = await extractSceneImages('input.mp4', {
219
+ sensitivity: 'low'
220
+ }, {
221
+ outputDir: './thumbnails',
222
+ format: 'jpg',
223
+ quality: 85,
224
+ filenameTemplate: 'scene_{frame}'
225
+ });
190
226
  ```
191
227
 
192
228
  ### API Reference
@@ -197,69 +233,89 @@ Detects scene changes in a video file.
197
233
 
198
234
  **Parameters:**
199
235
  - `videoPath` (string): Path to input video file
200
- - `options` (object, optional):
201
- - `sensitivity` ('low' | 'medium' | 'high'): Detection sensitivity (default: 'medium')
202
- - `searchRange` ('auto' | 'small' | 'medium' | 'large'): Motion search range (default: 'medium')
203
- - `onProgress` (function): Callback for progress updates
236
+ - `options` (DetectionOptions, optional):
237
+ - `sensitivity` ('low' | 'medium' | 'high' | 'custom'): Detection sensitivity
238
+ - `customThresholds` ({ intraThresh, intraThresh2 }): Custom threshold values (when sensitivity='custom')
239
+ - `searchRange` ('auto' | 'small' | 'medium' | 'large'): Motion search range
240
+ - `signal` (AbortSignal): For cancellation support
241
+ - `temporalSmoothing` ({ enabled, windowSize, minConsecutive }): Temporal smoothing config
242
+ - `onProgress` (function): Progress callback with fps, eta, scenesDetected
204
243
  - `onScene` (function): Callback for each detected scene
205
244
 
206
- **Returns:** Promise<DetectionResult>
245
+ **Returns:** `Promise<DetectionResult>`
207
246
 
208
- **DetectionResult:**
209
247
  ```typescript
210
- {
248
+ interface DetectionResult {
211
249
  scenes: Array<{
212
250
  frameNumber: number;
213
- timestamp: number; // Seconds
214
- timecode: string; // HH:MM:SS.mmm
251
+ timestamp: number; // Seconds
252
+ timecode: string; // HH:MM:SS.mmm
253
+ confidence: number; // 0-1
254
+ duration: number; // Seconds until next scene
255
+ frameCount: number; // Frames until next scene
215
256
  }>;
216
257
  metadata: {
217
258
  totalFrames: number;
218
- duration: number; // Seconds
259
+ duration: number;
219
260
  fps: number;
220
- resolution: {
221
- width: number;
222
- height: number;
223
- };
261
+ resolution: { width: number; height: number };
262
+ codec?: string;
263
+ pixelFormat?: string;
264
+ bitrate?: number;
265
+ };
266
+ stats: {
267
+ processingTime: number;
268
+ framesPerSecond: number;
224
269
  };
225
270
  }
226
271
  ```
227
272
 
228
- ## Supported Video Formats
273
+ #### `extractSceneImages(videoPath, options?, imageOptions?)`
229
274
 
230
- Keyframes supports any video format that FFmpeg can decode, including:
275
+ Detects scenes and extracts thumbnail images in a single FFmpeg pass.
231
276
 
232
- - MP4 (`.mp4`, `.m4v`)
233
- - Matroska (`.mkv`)
234
- - AVI (`.avi`)
235
- - WebM (`.webm`)
236
- - MOV (`.mov`)
237
- - FLV (`.flv`)
238
- - And many more...
277
+ **Additional parameter:**
278
+ - `imageOptions` (FrameImageOptions):
279
+ - `outputDir` (string): Output directory
280
+ - `format` ('jpg' | 'png' | 'bmp'): Image format
281
+ - `quality` (number): JPEG quality 1-100
282
+ - `width` (number): Output width (maintains aspect ratio)
283
+ - `filenameTemplate` (string): Use `{frame}` and `{timestamp}` placeholders
239
284
 
240
285
  ## How It Works
241
286
 
242
- Keyframes uses Xvid's motion estimation algorithm to detect scene changes:
243
-
244
- 1. **Frame Extraction**: FFmpeg extracts grayscale frames from the video
245
- 2. **Motion Analysis**: WebAssembly-compiled C code analyzes motion vectors between consecutive frames
246
- 3. **Scene Detection**: Frames with high motion complexity are identified as scene changes
247
- 4. **Output Formatting**: Results are formatted according to the requested output format
248
-
249
- The algorithm is based on [vapoursynth-wwxd](https://github.com/dubhater/vapoursynth-wwxd) by dubhater, which itself uses Xvid's motion estimation code.
287
+ 1. **Frame Extraction**: FFmpeg extracts grayscale frames via streaming ring buffer with zero-copy alternating buffers
288
+ 2. **Quick Reject**: Sampled pixel comparison (every 64th pixel) skips nearly-identical frames without touching WASM
289
+ 3. **Motion Analysis**: WebAssembly-compiled Xvid motion estimation with configurable thresholds and SIMD acceleration
290
+ 4. **Confidence Scoring**: Raw sSAD scores are normalized to 0-1 confidence values
291
+ 5. **Fade Detection**: Drift comparison against last keyframe catches gradual dissolves lasting 30+ frames
292
+ 6. **Temporal Smoothing**: Optional sliding window filter suppresses flashes and merges detection clusters
293
+ 7. **Double Buffering**: Previous frame stays in WASM memory between calls, halving memory copies
250
294
 
251
295
  ## Performance
252
296
 
253
- Optimized for speed and accuracy:
254
- - **Processing speed**: 35-45 fps on 1080p video (modern hardware)
255
- - **Memory usage**: ~200-300 MB with efficient buffer management
256
- - **Accuracy**: Matches vapoursynth-wwxd output (100% accurate)
257
- - **Optimizations**: WASM SIMD, pre-allocated buffers, ring buffer streaming
297
+ - **Processing speed**: 80-150+ fps on 1080p video (with quick-reject, most frames skip WASM entirely)
298
+ - **Memory usage**: ~200-300 MB with pre-allocated WASM buffers and buffer pooling
299
+ - **4K support**: Auto-sized ring buffer scales to any resolution
300
+ - **Optimizations**: WASM SIMD, double-buffered frames, pre-allocated macroblock array, zero-alloc frame extraction, quick-reject filtering
258
301
 
259
302
  ## Requirements
260
303
 
261
304
  - **Node.js**: 18.0.0 or higher
262
- - **FFmpeg**: Automatically installed via `@ffmpeg-installer/ffmpeg`
305
+ - **FFmpeg & FFprobe**: Automatically installed via `@ffmpeg-installer/ffmpeg` and `@ffprobe-installer/ffprobe`
306
+
307
+ ## Building from Source
308
+
309
+ ```bash
310
+ # Install dependencies
311
+ npm install
312
+
313
+ # Build WASM (requires Emscripten SDK)
314
+ npm run build:wasm
315
+
316
+ # Build TypeScript + bundle
317
+ npm run build
318
+ ```
263
319
 
264
320
  ## License
265
321
 
@@ -269,12 +325,7 @@ This project is based on:
269
325
  - [vapoursynth-wwxd](https://github.com/dubhater/vapoursynth-wwxd) by dubhater (GPL-2.0)
270
326
  - Xvid's motion estimation algorithm (GPL-2.0)
271
327
 
272
- ## Contributing
273
-
274
- Contributions are welcome! Please feel free to submit issues or pull requests.
275
-
276
328
  ## Credits
277
329
 
278
330
  - Original vapoursynth-wwxd plugin: [dubhater](https://github.com/dubhater)
279
331
  - Xvid motion estimation algorithm: [Xvid Team](https://www.xvid.com)
280
- - JavaScript/WASM port: This project
package/bin/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * Keyframes CLI - Simple command-line interface for scene detection
@@ -9,7 +9,7 @@
9
9
  * keyframes video.mp4 --sensitivity high
10
10
  */
11
11
 
12
- const { detectSceneChanges } = require('../dist/keyframes.cjs.js');
12
+ const { detectSceneChanges, extractSceneImages } = require('../dist/keyframes.cjs.js');
13
13
  const path = require('path');
14
14
  const fs = require('fs');
15
15
 
@@ -28,18 +28,21 @@ Examples:
28
28
  scenecut video.mkv --output keyframes.txt --format aegisub
29
29
  scenecut movie.mp4 --sensitivity high --format timecode
30
30
  scenecut video.mp4 --format csv --output scenes.csv
31
+ scenecut video.mp4 --thumbnails ./thumbs --timeout 120
31
32
 
32
33
  Options:
33
34
  --output, -o <file> Output file (default: {filename}_keyframes.txt)
34
35
  --format, -f <format> Output format: json|csv|aegisub|timecode (default: aegisub)
35
- --sensitivity, -s <level> Sensitivity: low|medium|high (default: medium)
36
+ --sensitivity, -s <level> Sensitivity: low|medium|high (default: low)
37
+ --timeout, -t <seconds> Abort after N seconds (default: no timeout)
38
+ --thumbnails <dir> Extract scene thumbnails to directory
36
39
  --quiet, -q Suppress progress output
37
40
  --verbose, -v Show detailed output
38
41
  --help, -h Show this help
39
42
 
40
43
  Formats:
41
- json JSON with full metadata
42
- csv CSV with frame,timestamp,timecode
44
+ json JSON with full metadata, confidence, and duration
45
+ csv CSV with frame,timestamp,timecode,confidence,duration
43
46
  aegisub (or txt) Aegisub keyframes format (frame numbers)
44
47
  timecode (or tc) Simple timecode list (HH:MM:SS.mmm)
45
48
 
@@ -60,9 +63,11 @@ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
60
63
  let videoPath = null;
61
64
  let outputPath = null; // Will be derived from video filename if not specified
62
65
  let outputFormat = 'aegisub'; // Default to Aegisub format
63
- let sensitivity = 'medium';
66
+ let sensitivity = 'low';
64
67
  let quiet = false;
65
68
  let verbose = false;
69
+ let timeout = 0;
70
+ let thumbnailDir = null;
66
71
 
67
72
  for (let i = 0; i < args.length; i++) {
68
73
  const arg = args[i];
@@ -73,6 +78,10 @@ for (let i = 0; i < args.length; i++) {
73
78
  outputFormat = args[++i];
74
79
  } else if (arg === '--sensitivity' || arg === '-s') {
75
80
  sensitivity = args[++i];
81
+ } else if (arg === '--timeout' || arg === '-t') {
82
+ timeout = parseInt(args[++i], 10);
83
+ } else if (arg === '--thumbnails') {
84
+ thumbnailDir = args[++i];
76
85
  } else if (arg === '--quiet' || arg === '-q') {
77
86
  quiet = true;
78
87
  } else if (arg === '--verbose' || arg === '-v') {
@@ -122,6 +131,12 @@ async function run() {
122
131
  console.log(`Input: ${videoPath}`);
123
132
  console.log(`Size: ${fileSize} MB`);
124
133
  console.log(`Output: ${path.resolve(outputPath)}`);
134
+ if (timeout > 0) {
135
+ console.log(`Timeout: ${timeout}s`);
136
+ }
137
+ if (thumbnailDir) {
138
+ console.log(`Thumbnails: ${path.resolve(thumbnailDir)}`);
139
+ }
125
140
  console.log('='.repeat(60));
126
141
  console.log();
127
142
  }
@@ -131,26 +146,35 @@ async function run() {
131
146
  let lastProgressFrame = 0;
132
147
  let sceneCount = 0;
133
148
 
149
+ // Set up AbortController for timeout
150
+ let controller = null;
151
+ let timeoutHandle = null;
152
+ if (timeout > 0) {
153
+ controller = new AbortController();
154
+ timeoutHandle = setTimeout(() => {
155
+ controller.abort();
156
+ }, timeout * 1000);
157
+ }
158
+
134
159
  try {
135
- const results = await detectSceneChanges(videoPath, {
160
+ const options = {
136
161
  sensitivity,
137
162
  searchRange: 'medium',
163
+ signal: controller ? controller.signal : undefined,
138
164
  onProgress: (progress) => {
139
165
  if (quiet) return;
140
166
 
141
167
  const now = Date.now();
142
168
  // Update every 3 seconds
143
169
  if (now - lastProgressTime > 3000 || progress.percent === 100) {
144
- const framesSinceLastUpdate = progress.currentFrame - lastProgressFrame;
145
- const timeSinceLastUpdate = (now - lastProgressTime) / 1000;
146
- const currentFps = framesSinceLastUpdate / timeSinceLastUpdate;
147
-
170
+ const fps = progress.fps || 0;
148
171
  const progressBar = createProgressBar(progress.percent);
149
172
  const etaStr = progress.eta ? ` ETA: ${formatTime(progress.eta)}` : '';
173
+ const scenesStr = progress.scenesDetected ? ` [${progress.scenesDetected} scenes]` : '';
150
174
 
151
175
  process.stdout.write(
152
176
  `\r${progressBar} ${progress.percent.toString().padStart(3)}% ` +
153
- `[${currentFps.toFixed(1)} fps]${etaStr}${' '.repeat(10)}`
177
+ `[${fps.toFixed(1)} fps]${etaStr}${scenesStr}${' '.repeat(10)}`
154
178
  );
155
179
 
156
180
  lastProgressTime = now;
@@ -160,11 +184,27 @@ async function run() {
160
184
  onScene: (scene) => {
161
185
  sceneCount++;
162
186
  if (verbose && !quiet) {
187
+ const confidenceStr = scene.confidence != null ? ` (confidence: ${(scene.confidence * 100).toFixed(0)}%)` : '';
163
188
  console.log();
164
- console.log(` Scene ${sceneCount}: Frame ${scene.frameNumber} at ${scene.timecode}`);
189
+ console.log(` Scene ${sceneCount}: Frame ${scene.frameNumber} at ${scene.timecode}${confidenceStr}`);
165
190
  }
166
191
  }
167
- });
192
+ };
193
+
194
+ let results;
195
+ if (thumbnailDir) {
196
+ results = await extractSceneImages(videoPath, options, {
197
+ outputDir: thumbnailDir,
198
+ format: 'jpg',
199
+ quality: 85
200
+ });
201
+ } else {
202
+ results = await detectSceneChanges(videoPath, options);
203
+ }
204
+
205
+ if (timeoutHandle) {
206
+ clearTimeout(timeoutHandle);
207
+ }
168
208
 
169
209
  const endTime = Date.now();
170
210
  const elapsed = (endTime - startTime) / 1000;
@@ -177,6 +217,15 @@ async function run() {
177
217
  console.log(`Scenes detected: ${results.scenes.length}`);
178
218
  console.log(`Processing time: ${formatTime(elapsed)}`);
179
219
  console.log(`Processing speed: ${(results.metadata.totalFrames / elapsed).toFixed(1)} fps`);
220
+ if (results.metadata.codec) {
221
+ console.log(`Video codec: ${results.metadata.codec}`);
222
+ }
223
+ if (results.metadata.pixelFormat) {
224
+ console.log(`Pixel format: ${results.metadata.pixelFormat}`);
225
+ }
226
+ if (results.metadata.bitrate) {
227
+ console.log(`Bitrate: ${(results.metadata.bitrate / 1000).toFixed(0)} kbps`);
228
+ }
180
229
  console.log('='.repeat(60));
181
230
  }
182
231
 
@@ -193,7 +242,7 @@ async function run() {
193
242
  outputPath = outputPath.replace('.json', '.txt');
194
243
  }
195
244
  } else if (outputFormat === 'timecode' || outputFormat === 'tc') {
196
- output = formatTimecode(results);
245
+ output = formatTimecodeList(results);
197
246
  if (outputPath.endsWith('.json')) {
198
247
  outputPath = outputPath.replace('.json', '.txt');
199
248
  }
@@ -206,13 +255,17 @@ async function run() {
206
255
 
207
256
  if (!quiet) {
208
257
  console.log(`Results saved to: ${path.resolve(outputPath)}`);
209
- console.log();
210
258
 
211
- // Print scene list
212
- console.log('Scene List:');
213
- results.scenes.forEach((scene, i) => {
214
- console.log(` ${(i + 1).toString().padStart(3)}. Frame ${scene.frameNumber.toString().padStart(6)} at ${scene.timecode}`);
215
- });
259
+ if (verbose) {
260
+ console.log();
261
+ // Print scene list
262
+ console.log('Scene List:');
263
+ results.scenes.forEach((scene, i) => {
264
+ const confidenceStr = scene.confidence != null ? ` (${(scene.confidence * 100).toFixed(0)}%)` : '';
265
+ const durationStr = scene.duration != null ? ` [${formatTime(scene.duration)}]` : '';
266
+ console.log(` ${(i + 1).toString().padStart(3)}. Frame ${scene.frameNumber.toString().padStart(6)} at ${scene.timecode}${confidenceStr}${durationStr}`);
267
+ });
268
+ }
216
269
  } else {
217
270
  // In quiet mode, just print the output
218
271
  console.log(output);
@@ -221,6 +274,16 @@ async function run() {
221
274
  process.exit(0);
222
275
 
223
276
  } catch (error) {
277
+ if (timeoutHandle) {
278
+ clearTimeout(timeoutHandle);
279
+ }
280
+
281
+ if (error.message === 'Detection aborted') {
282
+ console.error();
283
+ console.error('Detection aborted (timeout or cancellation).');
284
+ process.exit(2);
285
+ }
286
+
224
287
  console.error();
225
288
  console.error('Error:', error.message);
226
289
  if (verbose) {
@@ -235,7 +298,7 @@ function createProgressBar(percent) {
235
298
  const width = 30;
236
299
  const filled = Math.round((percent / 100) * width);
237
300
  const empty = width - filled;
238
- return '[' + ''.repeat(filled) + ''.repeat(empty) + ']';
301
+ return '[' + '\u2588'.repeat(filled) + '\u2591'.repeat(empty) + ']';
239
302
  }
240
303
 
241
304
  function formatTime(seconds) {
@@ -253,9 +316,12 @@ function formatTime(seconds) {
253
316
  }
254
317
 
255
318
  function formatCSV(results) {
256
- let csv = 'frame,timestamp,timecode\n';
319
+ let csv = 'frame,timestamp,timecode,confidence,duration,frameCount\n';
257
320
  results.scenes.forEach(scene => {
258
- csv += `${scene.frameNumber},${scene.timestamp},${scene.timecode || ''}\n`;
321
+ const conf = scene.confidence != null ? scene.confidence.toFixed(4) : '';
322
+ const dur = scene.duration != null ? scene.duration.toFixed(3) : '';
323
+ const fc = scene.frameCount != null ? scene.frameCount : '';
324
+ csv += `${scene.frameNumber},${scene.timestamp},${scene.timecode || ''},${conf},${dur},${fc}\n`;
259
325
  });
260
326
  return csv;
261
327
  }
@@ -271,7 +337,7 @@ function formatAegisub(results) {
271
337
  return output;
272
338
  }
273
339
 
274
- function formatTimecode(results) {
340
+ function formatTimecodeList(results) {
275
341
  // Simple timecode list - one per line
276
342
  // Can be used with various subtitle tools
277
343
  let output = '';