@doedja/scenecut 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -0
- package/bin/cli.js +293 -0
- package/dist/decoder/ffmpeg-decoder.d.ts +50 -0
- package/dist/decoder/ffmpeg-decoder.d.ts.map +1 -0
- package/dist/decoder/ffmpeg-decoder.js +269 -0
- package/dist/decoder/ffmpeg-decoder.js.map +1 -0
- package/dist/decoder/frame-buffer.d.ts +81 -0
- package/dist/decoder/frame-buffer.d.ts.map +1 -0
- package/dist/decoder/frame-buffer.js +123 -0
- package/dist/decoder/frame-buffer.js.map +1 -0
- package/dist/detection/detector.d.ts +19 -0
- package/dist/detection/detector.d.ts.map +1 -0
- package/dist/detection/detector.js +126 -0
- package/dist/detection/detector.js.map +1 -0
- package/dist/detection/wasm-bridge.d.ts +82 -0
- package/dist/detection/wasm-bridge.d.ts.map +1 -0
- package/dist/detection/wasm-bridge.js +182 -0
- package/dist/detection/wasm-bridge.js.map +1 -0
- package/dist/detection.wasm.js +2 -0
- package/dist/detection.wasm.wasm +0 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/keyframes.cjs.js +985 -0
- package/dist/keyframes.cjs.js.map +1 -0
- package/dist/keyframes.esm.js +946 -0
- package/dist/keyframes.esm.js.map +1 -0
- package/dist/types/index.d.ts +225 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/buffer-pool.d.ts +44 -0
- package/dist/utils/buffer-pool.d.ts.map +1 -0
- package/dist/utils/buffer-pool.js +81 -0
- package/dist/utils/buffer-pool.js.map +1 -0
- package/dist/utils/frame-processor.d.ts +48 -0
- package/dist/utils/frame-processor.d.ts.map +1 -0
- package/dist/utils/frame-processor.js +112 -0
- package/dist/utils/frame-processor.js.map +1 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# scenecut
|
|
2
|
+
|
|
3
|
+
Fast, accurate scene change detection for Node.js using Xvid's motion estimation algorithm compiled to WebAssembly.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
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
|
|
11
|
+
- **Cross-platform**: Works on Windows, Linux, and macOS
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
### Global Installation (CLI)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @doedja/scenecut
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Local Installation (API)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @doedja/scenecut
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CLI Usage
|
|
28
|
+
|
|
29
|
+
### Basic Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Simple - detects scenes and saves to Aegisub format (default)
|
|
33
|
+
# Creates input_keyframes.txt
|
|
34
|
+
scenecut "input.mkv"
|
|
35
|
+
|
|
36
|
+
# Specify custom output filename
|
|
37
|
+
scenecut "video.mkv" -o keyframes.txt
|
|
38
|
+
|
|
39
|
+
# For timecode output
|
|
40
|
+
scenecut "video.mp4" --format timecode -o timecodes.txt
|
|
41
|
+
|
|
42
|
+
# JSON format with full metadata
|
|
43
|
+
scenecut "movie.avi" --format json
|
|
44
|
+
|
|
45
|
+
# CSV format for spreadsheet analysis
|
|
46
|
+
scenecut "movie.avi" --format csv -o scenes.csv
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### CLI Options
|
|
50
|
+
|
|
51
|
+
| Option | Alias | Description | Default |
|
|
52
|
+
|--------|-------|-------------|---------|
|
|
53
|
+
| `--format` | `-f` | Output format: `aegisub`, `json`, `csv`, `timecode` | `aegisub` |
|
|
54
|
+
| `--output` | `-o` | Output file path | `{filename}_keyframes.txt` |
|
|
55
|
+
| `--sensitivity` | `-s` | Detection sensitivity: `low`, `medium`, `high` | `medium` |
|
|
56
|
+
| `--quiet` | `-q` | Suppress progress output | `false` |
|
|
57
|
+
| `--verbose` | `-v` | Show detailed output including each scene | `false` |
|
|
58
|
+
| `--help` | `-h` | Show help message | - |
|
|
59
|
+
|
|
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
|
|
71
|
+
|
|
72
|
+
# Verbose mode with detailed scene information
|
|
73
|
+
scenecut "movie.mkv" --verbose
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Output Formats
|
|
77
|
+
|
|
78
|
+
### Aegisub Format (`.txt`)
|
|
79
|
+
|
|
80
|
+
Aegisub keyframes format for subtitle timing:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
# keyframe format v1
|
|
84
|
+
fps 23.976
|
|
85
|
+
0
|
|
86
|
+
143
|
|
87
|
+
287
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Aegisub Workflow:**
|
|
91
|
+
1. Generate keyframes: `scenecut "video.mkv" -f aegisub -o keyframes.txt`
|
|
92
|
+
2. In Aegisub: **Video** → **Open Keyframes** → Select `keyframes.txt`
|
|
93
|
+
3. Keyframes appear as visual markers on the timeline for precise subtitle timing
|
|
94
|
+
|
|
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
|
+
### CSV Format (`.csv`)
|
|
106
|
+
|
|
107
|
+
Spreadsheet-compatible format:
|
|
108
|
+
|
|
109
|
+
```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
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### JSON Format (`.json`)
|
|
117
|
+
|
|
118
|
+
Complete metadata and scene information:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"scenes": [
|
|
123
|
+
{
|
|
124
|
+
"frameNumber": 0,
|
|
125
|
+
"timestamp": 0.0,
|
|
126
|
+
"timecode": "00:00:00.000"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"frameNumber": 143,
|
|
130
|
+
"timestamp": 5.964,
|
|
131
|
+
"timecode": "00:00:05.964"
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
"metadata": {
|
|
135
|
+
"totalFrames": 3000,
|
|
136
|
+
"duration": 125.08,
|
|
137
|
+
"fps": 23.976,
|
|
138
|
+
"resolution": {
|
|
139
|
+
"width": 1920,
|
|
140
|
+
"height": 1080
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Programmatic API
|
|
147
|
+
|
|
148
|
+
### Basic Usage
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
const { detectSceneChanges } = require('@doedja/scenecut');
|
|
152
|
+
|
|
153
|
+
(async () => {
|
|
154
|
+
const results = await detectSceneChanges('input.mp4');
|
|
155
|
+
|
|
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
|
+
})();
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Advanced Usage with Options
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
const { detectSceneChanges } = require('@doedja/scenecut');
|
|
167
|
+
|
|
168
|
+
const results = await detectSceneChanges('input.mp4', {
|
|
169
|
+
sensitivity: 'high', // 'low' | 'medium' | 'high'
|
|
170
|
+
searchRange: 'medium', // Motion search range
|
|
171
|
+
|
|
172
|
+
// Progress callback
|
|
173
|
+
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`);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// Scene detection callback
|
|
180
|
+
onScene: (scene) => {
|
|
181
|
+
console.log(`Scene detected at frame ${scene.frameNumber}`);
|
|
182
|
+
console.log(`Timecode: ${scene.timecode}`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
console.log('Detection complete!');
|
|
187
|
+
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}`);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### API Reference
|
|
193
|
+
|
|
194
|
+
#### `detectSceneChanges(videoPath, options?)`
|
|
195
|
+
|
|
196
|
+
Detects scene changes in a video file.
|
|
197
|
+
|
|
198
|
+
**Parameters:**
|
|
199
|
+
- `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
|
|
204
|
+
- `onScene` (function): Callback for each detected scene
|
|
205
|
+
|
|
206
|
+
**Returns:** Promise<DetectionResult>
|
|
207
|
+
|
|
208
|
+
**DetectionResult:**
|
|
209
|
+
```typescript
|
|
210
|
+
{
|
|
211
|
+
scenes: Array<{
|
|
212
|
+
frameNumber: number;
|
|
213
|
+
timestamp: number; // Seconds
|
|
214
|
+
timecode: string; // HH:MM:SS.mmm
|
|
215
|
+
}>;
|
|
216
|
+
metadata: {
|
|
217
|
+
totalFrames: number;
|
|
218
|
+
duration: number; // Seconds
|
|
219
|
+
fps: number;
|
|
220
|
+
resolution: {
|
|
221
|
+
width: number;
|
|
222
|
+
height: number;
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Supported Video Formats
|
|
229
|
+
|
|
230
|
+
Keyframes supports any video format that FFmpeg can decode, including:
|
|
231
|
+
|
|
232
|
+
- MP4 (`.mp4`, `.m4v`)
|
|
233
|
+
- Matroska (`.mkv`)
|
|
234
|
+
- AVI (`.avi`)
|
|
235
|
+
- WebM (`.webm`)
|
|
236
|
+
- MOV (`.mov`)
|
|
237
|
+
- FLV (`.flv`)
|
|
238
|
+
- And many more...
|
|
239
|
+
|
|
240
|
+
## How It Works
|
|
241
|
+
|
|
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.
|
|
250
|
+
|
|
251
|
+
## Performance
|
|
252
|
+
|
|
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
|
|
258
|
+
|
|
259
|
+
## Requirements
|
|
260
|
+
|
|
261
|
+
- **Node.js**: 18.0.0 or higher
|
|
262
|
+
- **FFmpeg**: Automatically installed via `@ffmpeg-installer/ffmpeg`
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
GPL-2.0
|
|
267
|
+
|
|
268
|
+
This project is based on:
|
|
269
|
+
- [vapoursynth-wwxd](https://github.com/dubhater/vapoursynth-wwxd) by dubhater (GPL-2.0)
|
|
270
|
+
- Xvid's motion estimation algorithm (GPL-2.0)
|
|
271
|
+
|
|
272
|
+
## Contributing
|
|
273
|
+
|
|
274
|
+
Contributions are welcome! Please feel free to submit issues or pull requests.
|
|
275
|
+
|
|
276
|
+
## Credits
|
|
277
|
+
|
|
278
|
+
- Original vapoursynth-wwxd plugin: [dubhater](https://github.com/dubhater)
|
|
279
|
+
- Xvid motion estimation algorithm: [Xvid Team](https://www.xvid.com)
|
|
280
|
+
- JavaScript/WASM port: This project
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyframes CLI - Simple command-line interface for scene detection
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* keyframes input.mp4
|
|
8
|
+
* keyframes input.mkv --output results.json
|
|
9
|
+
* keyframes video.mp4 --sensitivity high
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { detectSceneChanges } = require('../dist/keyframes.cjs.js');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
// Parse command line arguments
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
// Help text
|
|
20
|
+
const HELP = `
|
|
21
|
+
Scenecut - Scene change detection for videos
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
scenecut <video-file> [options]
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
scenecut input.mp4
|
|
28
|
+
scenecut video.mkv --output keyframes.txt --format aegisub
|
|
29
|
+
scenecut movie.mp4 --sensitivity high --format timecode
|
|
30
|
+
scenecut video.mp4 --format csv --output scenes.csv
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--output, -o <file> Output file (default: {filename}_keyframes.txt)
|
|
34
|
+
--format, -f <format> Output format: json|csv|aegisub|timecode (default: aegisub)
|
|
35
|
+
--sensitivity, -s <level> Sensitivity: low|medium|high (default: medium)
|
|
36
|
+
--quiet, -q Suppress progress output
|
|
37
|
+
--verbose, -v Show detailed output
|
|
38
|
+
--help, -h Show this help
|
|
39
|
+
|
|
40
|
+
Formats:
|
|
41
|
+
json JSON with full metadata
|
|
42
|
+
csv CSV with frame,timestamp,timecode
|
|
43
|
+
aegisub (or txt) Aegisub keyframes format (frame numbers)
|
|
44
|
+
timecode (or tc) Simple timecode list (HH:MM:SS.mmm)
|
|
45
|
+
|
|
46
|
+
Video Formats:
|
|
47
|
+
Supports MP4, MKV, AVI, WebM, MOV, and any format FFmpeg supports
|
|
48
|
+
|
|
49
|
+
Output:
|
|
50
|
+
Results are saved to the output file and printed to stdout
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
// Show help
|
|
54
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
55
|
+
console.log(HELP);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse arguments
|
|
60
|
+
let videoPath = null;
|
|
61
|
+
let outputPath = null; // Will be derived from video filename if not specified
|
|
62
|
+
let outputFormat = 'aegisub'; // Default to Aegisub format
|
|
63
|
+
let sensitivity = 'medium';
|
|
64
|
+
let quiet = false;
|
|
65
|
+
let verbose = false;
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < args.length; i++) {
|
|
68
|
+
const arg = args[i];
|
|
69
|
+
|
|
70
|
+
if (arg === '--output' || arg === '-o') {
|
|
71
|
+
outputPath = args[++i];
|
|
72
|
+
} else if (arg === '--format' || arg === '-f') {
|
|
73
|
+
outputFormat = args[++i];
|
|
74
|
+
} else if (arg === '--sensitivity' || arg === '-s') {
|
|
75
|
+
sensitivity = args[++i];
|
|
76
|
+
} else if (arg === '--quiet' || arg === '-q') {
|
|
77
|
+
quiet = true;
|
|
78
|
+
} else if (arg === '--verbose' || arg === '-v') {
|
|
79
|
+
verbose = true;
|
|
80
|
+
} else if (!arg.startsWith('-')) {
|
|
81
|
+
videoPath = arg;
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`Unknown option: ${arg}`);
|
|
84
|
+
console.error('Run "scenecut --help" for usage');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validate video path
|
|
90
|
+
if (!videoPath) {
|
|
91
|
+
console.error('Error: No video file specified');
|
|
92
|
+
console.error('Usage: scenecut <video-file>');
|
|
93
|
+
console.error('Run "scenecut --help" for more information');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Resolve video path
|
|
98
|
+
videoPath = path.resolve(videoPath);
|
|
99
|
+
|
|
100
|
+
if (!fs.existsSync(videoPath)) {
|
|
101
|
+
console.error(`Error: Video file not found: ${videoPath}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Generate default output path if not specified
|
|
106
|
+
if (!outputPath) {
|
|
107
|
+
const videoBasename = path.basename(videoPath, path.extname(videoPath));
|
|
108
|
+
const extension = (outputFormat === 'json') ? '.json' :
|
|
109
|
+
(outputFormat === 'csv') ? '.csv' : '.txt';
|
|
110
|
+
outputPath = `${videoBasename}_keyframes${extension}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Get file info
|
|
114
|
+
const stats = fs.statSync(videoPath);
|
|
115
|
+
const fileSize = (stats.size / (1024 * 1024)).toFixed(2);
|
|
116
|
+
|
|
117
|
+
// Main processing function
|
|
118
|
+
async function run() {
|
|
119
|
+
if (!quiet) {
|
|
120
|
+
console.log('Scenecut - Scene Detection');
|
|
121
|
+
console.log('='.repeat(60));
|
|
122
|
+
console.log(`Input: ${videoPath}`);
|
|
123
|
+
console.log(`Size: ${fileSize} MB`);
|
|
124
|
+
console.log(`Output: ${path.resolve(outputPath)}`);
|
|
125
|
+
console.log('='.repeat(60));
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const startTime = Date.now();
|
|
130
|
+
let lastProgressTime = startTime;
|
|
131
|
+
let lastProgressFrame = 0;
|
|
132
|
+
let sceneCount = 0;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const results = await detectSceneChanges(videoPath, {
|
|
136
|
+
sensitivity,
|
|
137
|
+
searchRange: 'medium',
|
|
138
|
+
onProgress: (progress) => {
|
|
139
|
+
if (quiet) return;
|
|
140
|
+
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
// Update every 3 seconds
|
|
143
|
+
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
|
+
|
|
148
|
+
const progressBar = createProgressBar(progress.percent);
|
|
149
|
+
const etaStr = progress.eta ? ` ETA: ${formatTime(progress.eta)}` : '';
|
|
150
|
+
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
`\r${progressBar} ${progress.percent.toString().padStart(3)}% ` +
|
|
153
|
+
`[${currentFps.toFixed(1)} fps]${etaStr}${' '.repeat(10)}`
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
lastProgressTime = now;
|
|
157
|
+
lastProgressFrame = progress.currentFrame;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
onScene: (scene) => {
|
|
161
|
+
sceneCount++;
|
|
162
|
+
if (verbose && !quiet) {
|
|
163
|
+
console.log();
|
|
164
|
+
console.log(` Scene ${sceneCount}: Frame ${scene.frameNumber} at ${scene.timecode}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const endTime = Date.now();
|
|
170
|
+
const elapsed = (endTime - startTime) / 1000;
|
|
171
|
+
|
|
172
|
+
if (!quiet) {
|
|
173
|
+
console.log('\n');
|
|
174
|
+
console.log('='.repeat(60));
|
|
175
|
+
console.log('Complete!');
|
|
176
|
+
console.log('='.repeat(60));
|
|
177
|
+
console.log(`Scenes detected: ${results.scenes.length}`);
|
|
178
|
+
console.log(`Processing time: ${formatTime(elapsed)}`);
|
|
179
|
+
console.log(`Processing speed: ${(results.metadata.totalFrames / elapsed).toFixed(1)} fps`);
|
|
180
|
+
console.log('='.repeat(60));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Format output
|
|
184
|
+
let output;
|
|
185
|
+
if (outputFormat === 'csv') {
|
|
186
|
+
output = formatCSV(results);
|
|
187
|
+
if (outputPath.endsWith('.json')) {
|
|
188
|
+
outputPath = outputPath.replace('.json', '.csv');
|
|
189
|
+
}
|
|
190
|
+
} else if (outputFormat === 'aegisub' || outputFormat === 'txt') {
|
|
191
|
+
output = formatAegisub(results);
|
|
192
|
+
if (outputPath.endsWith('.json')) {
|
|
193
|
+
outputPath = outputPath.replace('.json', '.txt');
|
|
194
|
+
}
|
|
195
|
+
} else if (outputFormat === 'timecode' || outputFormat === 'tc') {
|
|
196
|
+
output = formatTimecode(results);
|
|
197
|
+
if (outputPath.endsWith('.json')) {
|
|
198
|
+
outputPath = outputPath.replace('.json', '.txt');
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
output = JSON.stringify(results, null, 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Save to file
|
|
205
|
+
fs.writeFileSync(outputPath, output);
|
|
206
|
+
|
|
207
|
+
if (!quiet) {
|
|
208
|
+
console.log(`Results saved to: ${path.resolve(outputPath)}`);
|
|
209
|
+
console.log();
|
|
210
|
+
|
|
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
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
// In quiet mode, just print the output
|
|
218
|
+
console.log(output);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
process.exit(0);
|
|
222
|
+
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error();
|
|
225
|
+
console.error('Error:', error.message);
|
|
226
|
+
if (verbose) {
|
|
227
|
+
console.error(error.stack);
|
|
228
|
+
}
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Helper functions
|
|
234
|
+
function createProgressBar(percent) {
|
|
235
|
+
const width = 30;
|
|
236
|
+
const filled = Math.round((percent / 100) * width);
|
|
237
|
+
const empty = width - filled;
|
|
238
|
+
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatTime(seconds) {
|
|
242
|
+
const h = Math.floor(seconds / 3600);
|
|
243
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
244
|
+
const s = Math.floor(seconds % 60);
|
|
245
|
+
|
|
246
|
+
if (h > 0) {
|
|
247
|
+
return `${h}h ${m}m ${s}s`;
|
|
248
|
+
} else if (m > 0) {
|
|
249
|
+
return `${m}m ${s}s`;
|
|
250
|
+
} else {
|
|
251
|
+
return `${s}s`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatCSV(results) {
|
|
256
|
+
let csv = 'frame,timestamp,timecode\n';
|
|
257
|
+
results.scenes.forEach(scene => {
|
|
258
|
+
csv += `${scene.frameNumber},${scene.timestamp},${scene.timecode || ''}\n`;
|
|
259
|
+
});
|
|
260
|
+
return csv;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function formatAegisub(results) {
|
|
264
|
+
// Aegisub keyframes format - simple text file with frame numbers
|
|
265
|
+
// One frame number per line
|
|
266
|
+
let output = '# keyframe format v1\n';
|
|
267
|
+
output += `fps ${results.metadata.fps}\n`;
|
|
268
|
+
results.scenes.forEach(scene => {
|
|
269
|
+
output += `${scene.frameNumber}\n`;
|
|
270
|
+
});
|
|
271
|
+
return output;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formatTimecode(results) {
|
|
275
|
+
// Simple timecode list - one per line
|
|
276
|
+
// Can be used with various subtitle tools
|
|
277
|
+
let output = '';
|
|
278
|
+
results.scenes.forEach(scene => {
|
|
279
|
+
output += `${scene.timecode || formatTimecodeFull(scene.timestamp)}\n`;
|
|
280
|
+
});
|
|
281
|
+
return output;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatTimecodeFull(seconds) {
|
|
285
|
+
const h = Math.floor(seconds / 3600);
|
|
286
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
287
|
+
const s = Math.floor(seconds % 60);
|
|
288
|
+
const ms = Math.floor((seconds % 1) * 1000);
|
|
289
|
+
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Run
|
|
293
|
+
run();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg Decoder - Extract frames from video files
|
|
3
|
+
*
|
|
4
|
+
* Uses fluent-ffmpeg to extract grayscale frames for scene detection
|
|
5
|
+
*/
|
|
6
|
+
import { RawFrame, VideoMetadata } from '../types';
|
|
7
|
+
import { FrameBuffer } from './frame-buffer';
|
|
8
|
+
export interface DecoderOptions {
|
|
9
|
+
/** Pixel format for extraction (default: 'gray') */
|
|
10
|
+
pixelFormat?: 'gray' | 'yuv420p';
|
|
11
|
+
/** Maximum frames to buffer in memory */
|
|
12
|
+
maxBufferFrames?: number;
|
|
13
|
+
/** Skip every N frames for testing */
|
|
14
|
+
skipFrames?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class FFmpegDecoder {
|
|
17
|
+
private videoPath;
|
|
18
|
+
private options;
|
|
19
|
+
private metadata;
|
|
20
|
+
private frameBuffer;
|
|
21
|
+
constructor(videoPath: string, options?: DecoderOptions);
|
|
22
|
+
/**
|
|
23
|
+
* Get video metadata
|
|
24
|
+
*/
|
|
25
|
+
getMetadata(): Promise<VideoMetadata>;
|
|
26
|
+
/**
|
|
27
|
+
* Parse frame rate from FFmpeg format (e.g., "30000/1001")
|
|
28
|
+
*/
|
|
29
|
+
private parseFps;
|
|
30
|
+
/**
|
|
31
|
+
* Extract frames as grayscale data
|
|
32
|
+
*
|
|
33
|
+
* @param onFrame Callback for each frame
|
|
34
|
+
* @param onProgress Optional progress callback
|
|
35
|
+
*/
|
|
36
|
+
extractFrames(onFrame: (frame: RawFrame) => Promise<void> | void, onProgress?: (current: number, total: number) => void): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Extract a single frame at specific frame number
|
|
39
|
+
*/
|
|
40
|
+
extractFrame(frameNumber: number): Promise<RawFrame>;
|
|
41
|
+
/**
|
|
42
|
+
* Get the frame buffer
|
|
43
|
+
*/
|
|
44
|
+
getFrameBuffer(): FrameBuffer;
|
|
45
|
+
/**
|
|
46
|
+
* Clean up resources
|
|
47
|
+
*/
|
|
48
|
+
destroy(): void;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=ffmpeg-decoder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ffmpeg-decoder.d.ts","sourceRoot":"","sources":["../../src/decoder/ffmpeg-decoder.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAsG7C,MAAM,WAAW,cAAc;IAC7B,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,yCAAyC;IACzC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,WAAW,CAAc;gBAErB,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB;IAU3D;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,CAAC;IAqC3C;;OAEG;IACH,OAAO,CAAC,QAAQ;IAQhB;;;;;OAKG;IACG,aAAa,CACjB,OAAO,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,EAClD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GACpD,OAAO,CAAC,IAAI,CAAC;IAwEhB;;OAEG;IACG,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAgD1D;;OAEG;IACH,cAAc,IAAI,WAAW;IAI7B;;OAEG;IACH,OAAO,IAAI,IAAI;CAGhB"}
|