@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 +158 -107
- package/bin/cli.js +91 -25
- package/dist/decoder/ffmpeg-decoder.d.ts +15 -3
- package/dist/decoder/ffmpeg-decoder.d.ts.map +1 -1
- package/dist/decoder/ffmpeg-decoder.js +135 -14
- package/dist/decoder/ffmpeg-decoder.js.map +1 -1
- package/dist/detection/detector.d.ts.map +1 -1
- package/dist/detection/detector.js +134 -17
- package/dist/detection/detector.js.map +1 -1
- package/dist/detection/temporal-smoother.d.ts +32 -0
- package/dist/detection/temporal-smoother.d.ts.map +1 -0
- package/dist/detection/temporal-smoother.js +88 -0
- package/dist/detection/temporal-smoother.js.map +1 -0
- package/dist/detection/wasm-bridge.d.ts +26 -23
- package/dist/detection/wasm-bridge.d.ts.map +1 -1
- package/dist/detection/wasm-bridge.js +107 -62
- package/dist/detection/wasm-bridge.js.map +1 -1
- package/dist/detection.wasm.js +1 -1
- package/dist/detection.wasm.wasm +0 -0
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/keyframes.cjs.js +488 -94
- package/dist/keyframes.cjs.js.map +1 -1
- package/dist/keyframes.esm.js +487 -95
- package/dist/keyframes.esm.js.map +1 -1
- package/dist/types/index.d.ts +36 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
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
|
|
8
|
-
- **Accurate**: Uses Xvid's proven motion estimation algorithm
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
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` | `
|
|
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
|
|
72
|
+
| `--verbose` | `-v` | Show detailed output including confidence | `false` |
|
|
58
73
|
| `--help` | `-h` | Show help message | - |
|
|
59
74
|
|
|
60
|
-
###
|
|
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
|
-
|
|
73
|
-
|
|
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**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
154
|
-
const results = await detectSceneChanges('input.mp4');
|
|
170
|
+
const results = await detectSceneChanges('input.mp4');
|
|
155
171
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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',
|
|
170
|
-
searchRange: 'medium',
|
|
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(
|
|
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
|
|
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
|
|
189
|
-
|
|
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` (
|
|
201
|
-
- `sensitivity` ('low' | 'medium' | 'high'): Detection sensitivity
|
|
202
|
-
- `
|
|
203
|
-
- `
|
|
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;
|
|
214
|
-
timecode: string;
|
|
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;
|
|
259
|
+
duration: number;
|
|
219
260
|
fps: number;
|
|
220
|
-
resolution: {
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
273
|
+
#### `extractSceneImages(videoPath, options?, imageOptions?)`
|
|
229
274
|
|
|
230
|
-
|
|
275
|
+
Detects scenes and extracts thumbnail images in a single FFmpeg pass.
|
|
231
276
|
|
|
232
|
-
|
|
233
|
-
-
|
|
234
|
-
-
|
|
235
|
-
-
|
|
236
|
-
-
|
|
237
|
-
-
|
|
238
|
-
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
254
|
-
- **
|
|
255
|
-
- **
|
|
256
|
-
- **
|
|
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:
|
|
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 = '
|
|
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
|
|
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
|
|
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
|
-
`[${
|
|
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 =
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
console.log(
|
|
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 '[' + '
|
|
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
|
-
|
|
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
|
|
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 = '';
|