video-sampler 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/LICENSE +21 -0
- package/README.md +582 -0
- package/package.json +43 -0
- package/src/audio/extractAudioBuffer.js +144 -0
- package/src/cli/args.js +102 -0
- package/src/cli/frameOptions.js +121 -0
- package/src/cli/main.js +46 -0
- package/src/cli/print.js +56 -0
- package/src/cli/processors.js +14 -0
- package/src/cli/videoFiles.js +48 -0
- package/src/frames/cleanup.js +19 -0
- package/src/frames/defaults.js +20 -0
- package/src/frames/extraction.js +279 -0
- package/src/frames/frameSampler.js +190 -0
- package/src/frames/hash.js +46 -0
- package/src/frames/lowInformation.js +52 -0
- package/src/frames/options.js +221 -0
- package/src/frames/probe.js +60 -0
- package/src/frames/selection.js +160 -0
- package/src/frames/timestamps.js +33 -0
- package/src/index.js +8 -0
- package/src/lib.d.ts +447 -0
- package/src/lib.js +2 -0
- package/src/shared/ffmpeg.js +100 -0
- package/src/shared/files.js +77 -0
- package/src/shared/numbers.js +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vedang Danej
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# video-sampler
|
|
2
|
+
|
|
3
|
+
Extract WAV audio and adaptive, non-duplicate sample frames from video files.
|
|
4
|
+
|
|
5
|
+
`video-sampler` is a Node.js package and CLI for extracting useful media outputs from video files. It can:
|
|
6
|
+
|
|
7
|
+
- extract clean WAV audio from a video
|
|
8
|
+
- sample representative visual frames across the full video
|
|
9
|
+
- avoid near-duplicate frames with perceptual image hashing
|
|
10
|
+
- reject mostly black, mostly white, or very low-information frames
|
|
11
|
+
- work with bundled FFmpeg/FFprobe binaries or custom binary paths
|
|
12
|
+
|
|
13
|
+
It is useful when you need audio, representative frames, or both from a video. Common use cases include video previews, media indexing, moderation pipelines, content analysis, scene sampling, presentation recordings, interview recordings, video resumes, async assessments, and any workflow where processing every frame would be wasteful.
|
|
14
|
+
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
- [Installation](#installation)
|
|
18
|
+
- [Requirements](#requirements)
|
|
19
|
+
- [Quick Start](#quick-start)
|
|
20
|
+
- [CLI](#cli)
|
|
21
|
+
- [Library](#library)
|
|
22
|
+
- [Demo](#demo)
|
|
23
|
+
- [What It Does](#what-it-does)
|
|
24
|
+
- [Audio Extraction](#audio-extraction)
|
|
25
|
+
- [Frame Sampling](#frame-sampling)
|
|
26
|
+
- [CLI Usage](#cli-usage)
|
|
27
|
+
- [CLI Options](#cli-options)
|
|
28
|
+
- [CLI Examples](#cli-examples)
|
|
29
|
+
- [Library API](#library-api)
|
|
30
|
+
- [`extractAudio(videoPath, options)`](#extractaudiovideopath-options)
|
|
31
|
+
- [`sampleFrames(videoPath, options)`](#sampleframesvideopath-options)
|
|
32
|
+
- [Frame Result](#frame-result)
|
|
33
|
+
- [Common Recipes](#common-recipes)
|
|
34
|
+
- [Error Handling](#error-handling)
|
|
35
|
+
- [Notes](#notes)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install video-sampler
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
This package is ESM-only.
|
|
47
|
+
|
|
48
|
+
Use ESM imports:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { extractAudio, sampleFrames } from 'video-sampler';
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If your project uses CommonJS, use dynamic `import()`:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
async function main() {
|
|
58
|
+
const { extractAudio, sampleFrames } = await import('video-sampler');
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
It includes default FFmpeg and FFprobe binaries through:
|
|
63
|
+
|
|
64
|
+
- `@ffmpeg-installer/ffmpeg`
|
|
65
|
+
- `@ffprobe-installer/ffprobe`
|
|
66
|
+
|
|
67
|
+
Advanced users can pass custom `ffmpegPath` and `ffprobePath` values if they want to use system-installed binaries or a specific FFmpeg build.
|
|
68
|
+
|
|
69
|
+
## Quick Start
|
|
70
|
+
|
|
71
|
+
### CLI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx video-sampler ./video.webm
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
By default, the CLI writes:
|
|
78
|
+
|
|
79
|
+
- WAV audio to `./audio`
|
|
80
|
+
- accepted frame images to `./frames`
|
|
81
|
+
|
|
82
|
+
Example with custom outputs:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx video-sampler ./video.mp4 --audio-dir ./output/audio --frames-dir ./output/frames --frames 20 --similarity-threshold 10 --min-gap-ms 1000
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Library
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { extractAudio, sampleFrames } from 'video-sampler';
|
|
92
|
+
|
|
93
|
+
const audio = await extractAudio('./video.webm', {
|
|
94
|
+
outputDir: './output/audio',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const frames = await sampleFrames('./video.webm', {
|
|
98
|
+
outputDir: './output/frames',
|
|
99
|
+
targetFrames: 20,
|
|
100
|
+
similarityThreshold: 10,
|
|
101
|
+
minGapMs: 1000,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(audio.path);
|
|
105
|
+
console.log(frames.frames.map((frame) => frame.path));
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Only need frames:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { sampleFrames } from 'video-sampler';
|
|
112
|
+
|
|
113
|
+
const result = await sampleFrames('./video.mp4', {
|
|
114
|
+
outputDir: './frames',
|
|
115
|
+
targetFrames: 30,
|
|
116
|
+
minGapMs: 100,
|
|
117
|
+
similarityThreshold: 5,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log(result.frames.map((frame) => frame.path));
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Demo
|
|
124
|
+
|
|
125
|
+
This demo uses a lecture video, where most of the useful visual information is in presentation slides.
|
|
126
|
+
|
|
127
|
+
[<img src="https://raw.githubusercontent.com/vedangdanej/video-sampler/main/demo/thumbnail.jpg" alt="Watch the MIT lecture video used for the video-sampler demo" width="720">](https://www.youtube.com/watch?v=4fTOrb1yBFU)
|
|
128
|
+
|
|
129
|
+
Command:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npx video-sampler ./video.mp4 --target-frames 15 --min-gap 500 --similarity-threshold 7
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Output: 10 representative frames. `similarityThreshold` is set to `7` so progressive slide reveals are not filtered too aggressively.
|
|
136
|
+
|
|
137
|
+
<img src="https://raw.githubusercontent.com/vedangdanej/video-sampler/main/demo/frame-grid.jpg" alt="10 representative demo frames extracted by video-sampler" width="760">
|
|
138
|
+
|
|
139
|
+
## What It Does
|
|
140
|
+
|
|
141
|
+
### Audio Extraction
|
|
142
|
+
|
|
143
|
+
`extractAudio()` converts the input video's audio track into a WAV file.
|
|
144
|
+
|
|
145
|
+
The generated WAV is:
|
|
146
|
+
|
|
147
|
+
- PCM signed 16-bit little-endian
|
|
148
|
+
- 16 kHz
|
|
149
|
+
- mono
|
|
150
|
+
- MIME type `audio/wav`
|
|
151
|
+
|
|
152
|
+
This is a practical format for speech-to-text, transcription, audio indexing, and other audio analysis systems.
|
|
153
|
+
|
|
154
|
+
### Frame Sampling
|
|
155
|
+
|
|
156
|
+
`sampleFrames()` does not simply take one frame every N seconds.
|
|
157
|
+
|
|
158
|
+
Instead, it:
|
|
159
|
+
|
|
160
|
+
1. probes video duration and metadata with FFprobe
|
|
161
|
+
2. calculates a realistic frame target based on duration and options
|
|
162
|
+
3. generates more candidate timestamps than the final target
|
|
163
|
+
4. extracts candidate frames with FFmpeg
|
|
164
|
+
5. rejects low-information frames
|
|
165
|
+
6. hashes candidates with perceptual image hashing
|
|
166
|
+
7. avoids near-duplicates using Hamming distance
|
|
167
|
+
8. keeps accepted frames and cleans up rejected candidates by default
|
|
168
|
+
9. adds fallback coverage frames when a video is visually repetitive
|
|
169
|
+
|
|
170
|
+
The product contract is:
|
|
171
|
+
|
|
172
|
+
> Give me up to N representative frames, spread across the full video, avoiding near-duplicates.
|
|
173
|
+
|
|
174
|
+
This works well for both mostly static videos and fast-moving clips. For a mostly still video, fallback coverage helps return useful frames across the timeline. For a fast-moving video, you can lower `minGapMs` and `similarityThreshold` to collect frames closer together while still avoiding exact duplicates.
|
|
175
|
+
|
|
176
|
+
## CLI Usage
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
video-sampler [videoPath] [options]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
You can also provide the video path with `--video`:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
video-sampler --video ./video.webm
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
If no video path is provided, the CLI tries to use the only supported video file in the current directory.
|
|
189
|
+
|
|
190
|
+
### CLI Options
|
|
191
|
+
|
|
192
|
+
| Option | Description | Default |
|
|
193
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
194
|
+
| `--video <path>` | Video file to process. | Uses positional path or the only supported video in the current directory |
|
|
195
|
+
| `--frames <n>` | Target number of frames to return. Same as `--target-frames`. | `12` |
|
|
196
|
+
| `--target-frames <n>` | Target number of frames to return. | `12` |
|
|
197
|
+
| `--max-frames <n>` | Hard upper limit for returned frames. | `24` |
|
|
198
|
+
| `--min-frames <n>` | Minimum frames to try to return when feasible. | `3` |
|
|
199
|
+
| `--min-gap <ms>` | Soft minimum gap between selected frames, in milliseconds. | `1000` |
|
|
200
|
+
| `--min-gap-ms <ms>` | Same as `--min-gap`. | `1000` |
|
|
201
|
+
| `--start-time-ms <ms>` | Start sampling at this timestamp, in milliseconds. | `0` |
|
|
202
|
+
| `--end-before-ms <ms>` | Stop sampling this many milliseconds before the video ends. | `0` |
|
|
203
|
+
| `--candidate-multiplier <n>` | Candidate count multiplier. More candidates can improve variety but increase processing time. | `4` |
|
|
204
|
+
| `--max-candidates <n>` | Maximum candidate frames to inspect. | `160` |
|
|
205
|
+
| `--similarity-threshold <n>` | Perceptual hash Hamming distance threshold for duplicate rejection. | `10` |
|
|
206
|
+
| `--compare-recent <n>` | Number of recent accepted frames to compare after early selection. | `5` |
|
|
207
|
+
| `--frames-dir <path>` | Output folder for accepted frames. | `./frames` |
|
|
208
|
+
| `--audio-dir <path>` | Output folder for WAV audio. | `./audio` |
|
|
209
|
+
| `--format <jpg\|png\|webp>` | Frame image format. | `jpg` |
|
|
210
|
+
| `--jpeg-quality <n>` | FFmpeg `q:v` value for JPG output. Lower is better quality. | `2` |
|
|
211
|
+
| `--ffmpeg-path <path>` | Custom FFmpeg binary path. | Bundled FFmpeg |
|
|
212
|
+
| `--ffprobe-path <path>` | Custom FFprobe binary path. | Bundled FFprobe |
|
|
213
|
+
| `--debug` | Print FFmpeg debug output from frame extraction. | `false` |
|
|
214
|
+
| `--no-fallback` | Disable fallback frame coverage. | Fallback enabled |
|
|
215
|
+
| `--keep-rejected` | Keep rejected candidate frame files for inspection. | Rejected candidates deleted |
|
|
216
|
+
| `--help`, `-h` | Print CLI help. | - |
|
|
217
|
+
|
|
218
|
+
### CLI Examples
|
|
219
|
+
|
|
220
|
+
Extract audio and about 20 representative frames:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npx video-sampler ./video.mp4 --frames 20
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Use custom output folders:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
npx video-sampler ./video.webm --audio-dir ./processed/audio --frames-dir ./processed/frames
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Be more strict about duplicate frames:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
npx video-sampler ./video.mp4 --frames 20 --similarity-threshold 16
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Skip the intro:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npx video-sampler ./video.mp4 --frames 12 --start-time-ms 5000
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Skip ending fade-outs or closing screens:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npx video-sampler ./video.mp4 --frames 12 --end-before-ms 500
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Keep all rejected candidate images for debugging:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npx video-sampler ./video.mp4 --keep-rejected --debug
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Use custom FFmpeg and FFprobe binaries:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npx video-sampler ./video.mp4 --ffmpeg-path /usr/local/bin/ffmpeg --ffprobe-path /usr/local/bin/ffprobe
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Library API
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
import { extractAudio, sampleFrames } from 'video-sampler';
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The package includes TypeScript declarations, so editors can show option names, defaults, and descriptions on hover.
|
|
269
|
+
|
|
270
|
+
## `extractAudio(videoPath, options)`
|
|
271
|
+
|
|
272
|
+
Extracts WAV audio from a video.
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
const audio = await extractAudio('./video.mp4', {
|
|
276
|
+
outputDir: './audio',
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Audio Options
|
|
281
|
+
|
|
282
|
+
| Option | Type | Description | Default |
|
|
283
|
+
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
|
|
284
|
+
| `outputDir` | `string` | Directory where the WAV file should be written. Created automatically if missing. Ignored when `outputPath` is provided. | `process.cwd()` |
|
|
285
|
+
| `outputPath` | `string` | Exact output path for the WAV file. Parent directories are created automatically. | `<outputDir>/<input-video-basename>.wav` |
|
|
286
|
+
| `ffmpegPath` | `string` | Custom FFmpeg binary path. | Bundled FFmpeg |
|
|
287
|
+
|
|
288
|
+
### Audio Result
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
{
|
|
292
|
+
path: '/absolute/path/to/video.wav',
|
|
293
|
+
mime: 'audio/wav',
|
|
294
|
+
ext: 'wav'
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### `outputDir` vs `outputPath`
|
|
299
|
+
|
|
300
|
+
Use `outputDir` when you want the package to name the WAV file for you:
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
await extractAudio('./videos/video.mp4', {
|
|
304
|
+
outputDir: './audio',
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
This writes:
|
|
309
|
+
|
|
310
|
+
```text
|
|
311
|
+
./audio/video.wav
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Use `outputPath` when you want full control over the final file path:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
await extractAudio('./videos/video.mp4', {
|
|
318
|
+
outputPath: './audio/session-123.wav',
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## `sampleFrames(videoPath, options)`
|
|
323
|
+
|
|
324
|
+
Extracts representative frames from a video.
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
const result = await sampleFrames('./video.mp4', {
|
|
328
|
+
outputDir: './frames',
|
|
329
|
+
targetFrames: 12,
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Accepted frame files are left on disk for downstream processing. Rejected candidate files are deleted by default.
|
|
334
|
+
Accepted frame filenames are ordered by selection order and timestamp, for example `frame-001-t001158ms.jpg`.
|
|
335
|
+
When `cleanupRejected` is `false`, rejected candidate filenames include their candidate order and timestamp, for example `candidate-048-t238350ms.jpg`.
|
|
336
|
+
|
|
337
|
+
### Frame Options
|
|
338
|
+
|
|
339
|
+
| Option | Type | Description | Default |
|
|
340
|
+
| ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
|
341
|
+
| `targetFrames` | `number` | Ideal number of frames to return. This is a target, not a guarantee. The sampler may return fewer frames when the video is short, visually repetitive, or has too few usable frames. | `12` |
|
|
342
|
+
| `maxFrames` | `number` | Hard upper limit for returned frames. The function never returns more than this. | `24` |
|
|
343
|
+
| `minFrames` | `number` | Minimum frames to try to return when feasible. This may still be missed if too few usable frames can be extracted. | `3` |
|
|
344
|
+
| `minGapMs` | `number` | Soft minimum spacing between selected frames, in milliseconds. The sampler may relax this during fallback selection to satisfy `minFrames`. | `1000` |
|
|
345
|
+
| `startTimeMs` | `number` | Start sampling at this timestamp, in milliseconds. Negative values are clamped to `0` with a warning. | `0` |
|
|
346
|
+
| `endBeforeMs` | `number` | Stop sampling this many milliseconds before the video ends. Useful for avoiding fade-outs, closing screens, or black ending frames. | `0` |
|
|
347
|
+
| `candidateMultiplier` | `number` | Multiplier used to decide how many candidate frames to inspect. With a resolved target of 12 and multiplier 4, the sampler tries about 48 candidates before filtering. | `4` |
|
|
348
|
+
| `maxCandidates` | `number` | Maximum candidate frames to inspect. Prevents excessive FFmpeg and hashing work on long videos. | `160` |
|
|
349
|
+
| `similarityThreshold` | `number` | Perceptual hash Hamming distance required for a frame to count as visually distinct. Higher values reject duplicates more strictly. | `10` |
|
|
350
|
+
| `compareRecentCount` | `number` | Number of recent accepted frames to compare once the accepted set is large. Early selection compares against all accepted frames. | `5` |
|
|
351
|
+
| `outputDir` | `string` | Directory where accepted frame files should be written. Created automatically if missing. | `process.cwd()` |
|
|
352
|
+
| `imageFormat` | `'jpg' \| 'jpeg' \| 'png' \| 'webp'` | Image format for extracted frames. `jpeg` is normalized to `jpg`. | `'jpg'` |
|
|
353
|
+
| `jpegQuality` | `number` | FFmpeg `q:v` value for JPEG output. Lower values produce better quality and larger files. Only applies to JPG output. | `2` |
|
|
354
|
+
| `cleanupRejected` | `boolean` | Delete rejected candidate frame files after selection. Set to `false` to inspect all candidates while debugging. | `true` |
|
|
355
|
+
| `includeFallbackFrames` | `boolean` | Add fallback frames for coverage when duplicate filtering returns too few frames. Useful for mostly still videos. | `true` |
|
|
356
|
+
| `debug` | `boolean` | Print FFmpeg extraction output. | `false` |
|
|
357
|
+
| `ffmpegPath` | `string` | Custom FFmpeg binary path. | Bundled FFmpeg |
|
|
358
|
+
| `ffprobePath` | `string` | Custom FFprobe binary path. | Bundled FFprobe |
|
|
359
|
+
|
|
360
|
+
### Understanding Key Frame Options
|
|
361
|
+
|
|
362
|
+
#### `targetFrames`
|
|
363
|
+
|
|
364
|
+
This is the number of frames you would like to get, not a strict promise.
|
|
365
|
+
|
|
366
|
+
For example, if you request 20 frames from a very short or visually repetitive video, the sampler may return fewer frames because it avoids near-duplicates and low-information images. If you want denser frame sampling from a fast-moving clip, lower `minGapMs` and use a lower `similarityThreshold`.
|
|
367
|
+
|
|
368
|
+
#### `candidateMultiplier`
|
|
369
|
+
|
|
370
|
+
The sampler extracts more candidate frames than it plans to return.
|
|
371
|
+
|
|
372
|
+
For example:
|
|
373
|
+
|
|
374
|
+
```js
|
|
375
|
+
await sampleFrames('./video.mp4', {
|
|
376
|
+
targetFrames: 12,
|
|
377
|
+
candidateMultiplier: 4,
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
This asks the sampler to inspect about 48 candidate timestamps before selecting the best representative frames.
|
|
382
|
+
|
|
383
|
+
Higher values can improve visual variety, but they also increase FFmpeg extraction and image hashing work.
|
|
384
|
+
|
|
385
|
+
#### `startTimeMs` and `endBeforeMs`
|
|
386
|
+
|
|
387
|
+
By default, the sampler considers the full usable video timeline. Use these options when you only want frames from part of a video:
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
const result = await sampleFrames('./video.mp4', {
|
|
391
|
+
startTimeMs: 30_000,
|
|
392
|
+
endBeforeMs: 500,
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`startTimeMs` skips the beginning of the video. `endBeforeMs` skips the end of the video, which is useful when recordings finish on fade-outs, app-closing screens, or black frames.
|
|
397
|
+
|
|
398
|
+
#### `similarityThreshold`
|
|
399
|
+
|
|
400
|
+
Each candidate frame is converted into a perceptual image hash. The sampler compares hashes using Hamming distance.
|
|
401
|
+
|
|
402
|
+
`similarityThreshold` is the minimum number of differing hash bits required for a candidate to count as visually distinct.
|
|
403
|
+
|
|
404
|
+
- lower values are more permissive and may allow similar frames
|
|
405
|
+
- higher values are stricter and may return fewer frames
|
|
406
|
+
- the default `10` is a practical starting point for general representative frame sampling
|
|
407
|
+
|
|
408
|
+
#### `cleanupRejected`
|
|
409
|
+
|
|
410
|
+
This is `true` by default.
|
|
411
|
+
|
|
412
|
+
When enabled, only accepted frame files remain in the output directory. Rejected candidate images are deleted.
|
|
413
|
+
|
|
414
|
+
Set it to `false` when debugging:
|
|
415
|
+
|
|
416
|
+
```js
|
|
417
|
+
await sampleFrames('./video.mp4', {
|
|
418
|
+
outputDir: './frames-debug',
|
|
419
|
+
cleanupRejected: false,
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Frame Result
|
|
424
|
+
|
|
425
|
+
`sampleFrames()` returns metadata, resolved options, selected frames, stats, and warnings.
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
{
|
|
429
|
+
video: {
|
|
430
|
+
path: './video.mp4',
|
|
431
|
+
durationSeconds: 180.42,
|
|
432
|
+
width: 1920,
|
|
433
|
+
height: 1080,
|
|
434
|
+
fps: 30
|
|
435
|
+
},
|
|
436
|
+
output: {
|
|
437
|
+
directory: '/absolute/path/to/frames'
|
|
438
|
+
},
|
|
439
|
+
options: {
|
|
440
|
+
resolvedTargetFrames: 12,
|
|
441
|
+
maxFrames: 24,
|
|
442
|
+
minFrames: 3,
|
|
443
|
+
minGapMs: 1000,
|
|
444
|
+
startTimeMs: 0,
|
|
445
|
+
endBeforeMs: 0,
|
|
446
|
+
candidateCount: 48,
|
|
447
|
+
similarityThreshold: 10
|
|
448
|
+
},
|
|
449
|
+
frames: [
|
|
450
|
+
{
|
|
451
|
+
path: '/absolute/path/to/frames/frame-id.jpg',
|
|
452
|
+
timestamp: 14.29,
|
|
453
|
+
hash: '...',
|
|
454
|
+
score: {
|
|
455
|
+
minDistanceToRecentAccepted: null,
|
|
456
|
+
acceptedReason: 'first_frame'
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
],
|
|
460
|
+
stats: {
|
|
461
|
+
candidatesExtracted: 48,
|
|
462
|
+
candidatesRejectedAsSimilar: 20,
|
|
463
|
+
candidatesRejectedForSpacing: 4,
|
|
464
|
+
candidatesRejectedAsLowInformation: 1,
|
|
465
|
+
fallbackFramesAdded: 0,
|
|
466
|
+
returnedFrames: 12
|
|
467
|
+
},
|
|
468
|
+
warnings: []
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`minDistanceToRecentAccepted` is `null` for the first accepted frame because there are no earlier accepted frames to compare
|
|
473
|
+
against.
|
|
474
|
+
|
|
475
|
+
### Accepted Frame Reasons
|
|
476
|
+
|
|
477
|
+
| Reason | Meaning |
|
|
478
|
+
| ------------------------ | ------------------------------------------------------------------------------------------ |
|
|
479
|
+
| `first_frame` | The first usable candidate frame was accepted. |
|
|
480
|
+
| `visually_distinct` | The frame was far enough from recently accepted frames by perceptual hash distance. |
|
|
481
|
+
| `fallback_even_coverage` | The frame was added to preserve basic coverage when the video was too visually repetitive. |
|
|
482
|
+
|
|
483
|
+
## Common Recipes
|
|
484
|
+
|
|
485
|
+
### Process a video upload
|
|
486
|
+
|
|
487
|
+
```js
|
|
488
|
+
import { extractAudio, sampleFrames } from 'video-sampler';
|
|
489
|
+
|
|
490
|
+
const videoPath = './uploads/video.webm';
|
|
491
|
+
|
|
492
|
+
const [audio, frames] = await Promise.all([
|
|
493
|
+
extractAudio(videoPath, {
|
|
494
|
+
outputDir: './processed/audio',
|
|
495
|
+
}),
|
|
496
|
+
sampleFrames(videoPath, {
|
|
497
|
+
outputDir: './processed/frames',
|
|
498
|
+
targetFrames: 12,
|
|
499
|
+
maxFrames: 24,
|
|
500
|
+
minGapMs: 1000,
|
|
501
|
+
}),
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
console.log({
|
|
505
|
+
audio: audio.path,
|
|
506
|
+
frames: frames.frames.map((frame) => frame.path),
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Prefer more visual variety
|
|
511
|
+
|
|
512
|
+
```js
|
|
513
|
+
const result = await sampleFrames('./lecture.mp4', {
|
|
514
|
+
targetFrames: 20,
|
|
515
|
+
candidateMultiplier: 6,
|
|
516
|
+
similarityThreshold: 14,
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Sample a fast-moving video more densely
|
|
521
|
+
|
|
522
|
+
```js
|
|
523
|
+
const result = await sampleFrames('./short-clip.mp4', {
|
|
524
|
+
targetFrames: 20,
|
|
525
|
+
minGapMs: 150,
|
|
526
|
+
similarityThreshold: 5,
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Use custom binaries
|
|
531
|
+
|
|
532
|
+
```js
|
|
533
|
+
const result = await sampleFrames('./video.mp4', {
|
|
534
|
+
ffmpegPath: '/opt/ffmpeg/bin/ffmpeg',
|
|
535
|
+
ffprobePath: '/opt/ffmpeg/bin/ffprobe',
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
```js
|
|
540
|
+
const audio = await extractAudio('./video.mp4', {
|
|
541
|
+
ffmpegPath: '/opt/ffmpeg/bin/ffmpeg',
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
## Error Handling
|
|
546
|
+
|
|
547
|
+
Both functions throw errors for fatal failures.
|
|
548
|
+
|
|
549
|
+
Examples include:
|
|
550
|
+
|
|
551
|
+
- input video file does not exist
|
|
552
|
+
- video duration cannot be probed
|
|
553
|
+
- FFmpeg cannot extract usable frames
|
|
554
|
+
- perceptual hash generation fails completely
|
|
555
|
+
- audio conversion fails
|
|
556
|
+
|
|
557
|
+
`sampleFrames()` may also return non-fatal `warnings`, such as deprecated option usage or fallback extraction behavior.
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
try {
|
|
561
|
+
const result = await sampleFrames('./video.mp4');
|
|
562
|
+
|
|
563
|
+
for (const warning of result.warnings) {
|
|
564
|
+
console.warn(warning);
|
|
565
|
+
}
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error('Video processing failed:', error.message);
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
## Notes
|
|
572
|
+
|
|
573
|
+
- Frame sampling is adaptive. Requested frame counts are targets, not guarantees.
|
|
574
|
+
- By default, frame candidates are spread across the full usable video duration.
|
|
575
|
+
- Accepted frame files stay on disk for downstream processing.
|
|
576
|
+
- Rejected candidate files are deleted by default.
|
|
577
|
+
- The package does not perform facial recognition, emotion detection, pose detection, transcription, or scoring.
|
|
578
|
+
- Processing happens locally with FFmpeg, FFprobe, Sharp, and perceptual hashing.
|
|
579
|
+
|
|
580
|
+
## License
|
|
581
|
+
|
|
582
|
+
MIT (c) Vedang Danej
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "video-sampler",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Extract WAV audio and adaptive, non-duplicate sample frames from video files.",
|
|
6
|
+
"author": "Vedang Danej",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "./src/lib.js",
|
|
9
|
+
"types": "./src/lib.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/lib.d.ts",
|
|
13
|
+
"import": "./src/lib.js"
|
|
14
|
+
},
|
|
15
|
+
"./cli": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"video-sampler": "./src/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"video",
|
|
25
|
+
"video-processing",
|
|
26
|
+
"frame-extraction",
|
|
27
|
+
"sample-frames",
|
|
28
|
+
"representative-frames",
|
|
29
|
+
"audio-extraction",
|
|
30
|
+
"wav",
|
|
31
|
+
"ffmpeg",
|
|
32
|
+
"ffprobe",
|
|
33
|
+
"media"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
37
|
+
"@ffprobe-installer/ffprobe": "^2.1.2",
|
|
38
|
+
"fluent-ffmpeg": "^2.1.3",
|
|
39
|
+
"sharp": "^0.34.5",
|
|
40
|
+
"sharp-phash": "^2.2.0",
|
|
41
|
+
"uuid": "^14.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|