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/src/lib.d.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link extractAudio}.
|
|
3
|
+
*/
|
|
4
|
+
export interface ExtractAudioOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Directory where the WAV file should be written.
|
|
7
|
+
*
|
|
8
|
+
* Default: `process.cwd()`
|
|
9
|
+
*
|
|
10
|
+
* If the directory does not exist, it is created automatically.
|
|
11
|
+
* Ignored when `outputPath` is provided.
|
|
12
|
+
*/
|
|
13
|
+
outputDir?: string;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Exact output path for the WAV file.
|
|
17
|
+
*
|
|
18
|
+
* Default: `<outputDir>/<input-video-basename>.wav`
|
|
19
|
+
*
|
|
20
|
+
* Parent directories are created automatically.
|
|
21
|
+
*/
|
|
22
|
+
outputPath?: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Custom FFmpeg binary path.
|
|
26
|
+
*
|
|
27
|
+
* Default: bundled `@ffmpeg-installer/ffmpeg` binary.
|
|
28
|
+
*/
|
|
29
|
+
ffmpegPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Result returned by {@link extractAudio}.
|
|
34
|
+
*/
|
|
35
|
+
export interface ExtractAudioResult {
|
|
36
|
+
/**
|
|
37
|
+
* Absolute path to the generated WAV file.
|
|
38
|
+
*/
|
|
39
|
+
path: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* MIME type for the generated audio.
|
|
43
|
+
*/
|
|
44
|
+
mime: 'audio/wav';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* File extension for the generated audio.
|
|
48
|
+
*/
|
|
49
|
+
ext: 'wav';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Options for {@link sampleFrames}.
|
|
54
|
+
*/
|
|
55
|
+
export interface SampleFramesOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Ideal number of frames to return.
|
|
58
|
+
*
|
|
59
|
+
* Default: `12`
|
|
60
|
+
*
|
|
61
|
+
* This is a target, not a guarantee. The sampler may return fewer frames
|
|
62
|
+
* when the video is short, visually repetitive, has broken timestamps, or
|
|
63
|
+
* FFmpeg cannot extract enough usable candidates.
|
|
64
|
+
*/
|
|
65
|
+
targetFrames?: number;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hard upper limit for returned frames.
|
|
69
|
+
*
|
|
70
|
+
* Default: `24`
|
|
71
|
+
*/
|
|
72
|
+
maxFrames?: number;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Minimum number of frames to try to return when feasible.
|
|
76
|
+
*
|
|
77
|
+
* Default: `3`
|
|
78
|
+
*
|
|
79
|
+
* This can still be missed if too few usable frames can be extracted.
|
|
80
|
+
*/
|
|
81
|
+
minFrames?: number;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Soft minimum spacing between selected frames, in milliseconds.
|
|
85
|
+
*
|
|
86
|
+
* Default: `1000`
|
|
87
|
+
*
|
|
88
|
+
* The sampler respects this during primary selection and may relax it during
|
|
89
|
+
* fallback selection to satisfy `minFrames`.
|
|
90
|
+
*/
|
|
91
|
+
minGapMs?: number;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start sampling at this timestamp, in milliseconds.
|
|
95
|
+
*
|
|
96
|
+
* Default: `0`
|
|
97
|
+
*
|
|
98
|
+
* Negative values are clamped to `0` with a warning.
|
|
99
|
+
*/
|
|
100
|
+
startTimeMs?: number;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Stop sampling this many milliseconds before the video ends.
|
|
104
|
+
*
|
|
105
|
+
* Default: `0`
|
|
106
|
+
*
|
|
107
|
+
* Example: `endBeforeMs: 500` keeps candidates at least 500ms before EOF.
|
|
108
|
+
* Negative values are clamped to `0` with a warning. If the value leaves no
|
|
109
|
+
* sampling window after `startTimeMs`, the sampler falls back to the full
|
|
110
|
+
* video and returns a warning.
|
|
111
|
+
*/
|
|
112
|
+
endBeforeMs?: number;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Multiplier used to decide how many candidate frames to inspect.
|
|
116
|
+
*
|
|
117
|
+
* Default: `4`
|
|
118
|
+
*
|
|
119
|
+
* Example: with a resolved target of 12, the sampler tries to inspect about
|
|
120
|
+
* 48 candidate frames before filtering them. Higher values can improve visual
|
|
121
|
+
* variety but increase processing time.
|
|
122
|
+
*/
|
|
123
|
+
candidateMultiplier?: number;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Maximum candidate frames to inspect.
|
|
127
|
+
*
|
|
128
|
+
* Default: `160`
|
|
129
|
+
*
|
|
130
|
+
* Prevents excessive FFmpeg and image hashing work on long videos.
|
|
131
|
+
*/
|
|
132
|
+
maxCandidates?: number;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Perceptual hash Hamming distance required for a frame to count as visually distinct.
|
|
136
|
+
*
|
|
137
|
+
* Default: `10`
|
|
138
|
+
*
|
|
139
|
+
* This is not a percentage. Each frame is converted into a 64-bit perceptual
|
|
140
|
+
* image hash, and this value is the minimum number of differing bits required
|
|
141
|
+
* for a candidate frame to be accepted. With the default `10`, a frame must
|
|
142
|
+
* differ by at least 10 of 64 hash bits from recently accepted frames.
|
|
143
|
+
*
|
|
144
|
+
* Higher values reject near-duplicates more strictly and may return fewer
|
|
145
|
+
* frames. Lower values are more permissive and may return more similar frames.
|
|
146
|
+
*/
|
|
147
|
+
similarityThreshold?: number;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Number of recent accepted frames to compare once the accepted set is large.
|
|
151
|
+
*
|
|
152
|
+
* Default: `5`
|
|
153
|
+
*
|
|
154
|
+
* The sampler compares against all accepted frames while the accepted set is
|
|
155
|
+
* small, then only this many recent frames for efficiency.
|
|
156
|
+
*/
|
|
157
|
+
compareRecentCount?: number;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Directory where accepted frame files should be written.
|
|
161
|
+
*
|
|
162
|
+
* Default: `process.cwd()`
|
|
163
|
+
*
|
|
164
|
+
* If the directory does not exist, it is created automatically.
|
|
165
|
+
*/
|
|
166
|
+
outputDir?: string;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Image format for extracted frames.
|
|
170
|
+
*
|
|
171
|
+
* Default: `'jpg'`
|
|
172
|
+
*/
|
|
173
|
+
imageFormat?: 'jpg' | 'jpeg' | 'png' | 'webp';
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* FFmpeg `q:v` value for JPEG output.
|
|
177
|
+
*
|
|
178
|
+
* Default: `2`
|
|
179
|
+
*
|
|
180
|
+
* Lower values produce better quality and larger files. Only applies when
|
|
181
|
+
* `imageFormat` is `'jpg'` or `'jpeg'`.
|
|
182
|
+
*/
|
|
183
|
+
jpegQuality?: number;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete rejected candidate frame files after selection.
|
|
187
|
+
*
|
|
188
|
+
* Default: `true`
|
|
189
|
+
*
|
|
190
|
+
* Set to `false` when debugging to inspect every candidate image the sampler
|
|
191
|
+
* considered.
|
|
192
|
+
*/
|
|
193
|
+
cleanupRejected?: boolean;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Add fallback frames for basic coverage when duplicate filtering is too strict.
|
|
197
|
+
*
|
|
198
|
+
* Default: `true`
|
|
199
|
+
*
|
|
200
|
+
* Useful for interview videos where the subject may remain visually still.
|
|
201
|
+
*/
|
|
202
|
+
includeFallbackFrames?: boolean;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Print FFmpeg extraction output.
|
|
206
|
+
*
|
|
207
|
+
* Default: `false`
|
|
208
|
+
*/
|
|
209
|
+
debug?: boolean;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Custom FFmpeg binary path.
|
|
213
|
+
*
|
|
214
|
+
* Default: bundled `@ffmpeg-installer/ffmpeg` binary.
|
|
215
|
+
*/
|
|
216
|
+
ffmpegPath?: string;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Custom FFprobe binary path.
|
|
220
|
+
*
|
|
221
|
+
* Default: bundled `@ffprobe-installer/ffprobe` binary.
|
|
222
|
+
*/
|
|
223
|
+
ffprobePath?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Video metadata returned by {@link sampleFrames}.
|
|
228
|
+
*/
|
|
229
|
+
export interface SampleFramesVideoMetadata {
|
|
230
|
+
/**
|
|
231
|
+
* Input video path passed to `sampleFrames`.
|
|
232
|
+
*/
|
|
233
|
+
path: string;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Probed video duration in seconds.
|
|
237
|
+
*/
|
|
238
|
+
durationSeconds: number;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Probed video width in pixels, or `null` when unavailable.
|
|
242
|
+
*/
|
|
243
|
+
width: number | null;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Probed video height in pixels, or `null` when unavailable.
|
|
247
|
+
*/
|
|
248
|
+
height: number | null;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Probed frames per second, or `null` when unavailable.
|
|
252
|
+
*/
|
|
253
|
+
fps: number | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolved sampling options returned by {@link sampleFrames}.
|
|
258
|
+
*/
|
|
259
|
+
export interface ResolvedSampleFramesOptions {
|
|
260
|
+
/**
|
|
261
|
+
* Final target frame count after applying duration and max-frame limits.
|
|
262
|
+
*/
|
|
263
|
+
resolvedTargetFrames: number;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Hard upper limit used for returned frames.
|
|
267
|
+
*/
|
|
268
|
+
maxFrames: number;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Minimum frame count the sampler tried to satisfy.
|
|
272
|
+
*/
|
|
273
|
+
minFrames: number;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Soft spacing used between selected frames, in milliseconds.
|
|
277
|
+
*/
|
|
278
|
+
minGapMs: number;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolved sampling-window start timestamp, in milliseconds.
|
|
282
|
+
*/
|
|
283
|
+
startTimeMs: number;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolved number of milliseconds skipped before the video end.
|
|
287
|
+
*/
|
|
288
|
+
endBeforeMs: number;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Number of candidate timestamps generated.
|
|
292
|
+
*/
|
|
293
|
+
candidateCount: number;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Perceptual hash Hamming distance threshold used for duplicate filtering.
|
|
297
|
+
*/
|
|
298
|
+
similarityThreshold: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Reason a sampled frame was accepted.
|
|
303
|
+
*/
|
|
304
|
+
export type AcceptedFrameReason =
|
|
305
|
+
| 'first_frame'
|
|
306
|
+
| 'visually_distinct'
|
|
307
|
+
| 'fallback_even_coverage';
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A selected representative frame.
|
|
311
|
+
*/
|
|
312
|
+
export interface SampledFrame {
|
|
313
|
+
/**
|
|
314
|
+
* Absolute path to the accepted frame image.
|
|
315
|
+
*
|
|
316
|
+
* Accepted frame files are named in timeline order, for example
|
|
317
|
+
* `frame-001-t001158ms.jpg`.
|
|
318
|
+
*
|
|
319
|
+
* When rejected candidates are kept for debugging, their filenames include
|
|
320
|
+
* candidate order and timestamp, for example `candidate-048-t238350ms.jpg`.
|
|
321
|
+
*/
|
|
322
|
+
path: string;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Timestamp, in seconds, associated with this frame.
|
|
326
|
+
*/
|
|
327
|
+
timestamp: number;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Perceptual image hash used for duplicate filtering.
|
|
331
|
+
*/
|
|
332
|
+
hash: string;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Selection metadata for this frame.
|
|
336
|
+
*/
|
|
337
|
+
score: {
|
|
338
|
+
/**
|
|
339
|
+
* Minimum Hamming distance to the compared accepted frames.
|
|
340
|
+
*
|
|
341
|
+
* `null` for the first accepted frame.
|
|
342
|
+
*/
|
|
343
|
+
minDistanceToRecentAccepted: number | null;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Why the frame was accepted.
|
|
347
|
+
*/
|
|
348
|
+
acceptedReason: AcceptedFrameReason;
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Sampling stats returned by {@link sampleFrames}.
|
|
354
|
+
*/
|
|
355
|
+
export interface SampleFramesStats {
|
|
356
|
+
/**
|
|
357
|
+
* Number of candidate frames successfully extracted.
|
|
358
|
+
*/
|
|
359
|
+
candidatesExtracted: number;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Number of candidates rejected as visually too similar.
|
|
363
|
+
*/
|
|
364
|
+
candidatesRejectedAsSimilar: number;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Number of candidates rejected because they violated spacing.
|
|
368
|
+
*/
|
|
369
|
+
candidatesRejectedForSpacing: number;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Number of candidates rejected as mostly black, mostly white, or too flat.
|
|
373
|
+
*/
|
|
374
|
+
candidatesRejectedAsLowInformation: number;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Number of fallback frames added for coverage.
|
|
378
|
+
*/
|
|
379
|
+
fallbackFramesAdded: number;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Number of frames returned.
|
|
383
|
+
*/
|
|
384
|
+
returnedFrames: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Result returned by {@link sampleFrames}.
|
|
389
|
+
*/
|
|
390
|
+
export interface SampleFramesResult {
|
|
391
|
+
/**
|
|
392
|
+
* Probed input video metadata.
|
|
393
|
+
*/
|
|
394
|
+
video: SampleFramesVideoMetadata;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Output information.
|
|
398
|
+
*/
|
|
399
|
+
output: {
|
|
400
|
+
/**
|
|
401
|
+
* Directory containing accepted frame files.
|
|
402
|
+
*/
|
|
403
|
+
directory: string;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Resolved sampling options used for this run.
|
|
408
|
+
*/
|
|
409
|
+
options: ResolvedSampleFramesOptions;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Accepted representative frames.
|
|
413
|
+
*/
|
|
414
|
+
frames: SampledFrame[];
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Runtime counters for candidate extraction and filtering.
|
|
418
|
+
*/
|
|
419
|
+
stats: SampleFramesStats;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Non-fatal issues encountered during sampling.
|
|
423
|
+
*/
|
|
424
|
+
warnings: string[];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Extract a WAV audio file from a video.
|
|
429
|
+
*
|
|
430
|
+
* The generated audio is PCM signed 16-bit little-endian, 16 kHz, mono.
|
|
431
|
+
*/
|
|
432
|
+
export function extractAudio(
|
|
433
|
+
videoPath: string,
|
|
434
|
+
options?: ExtractAudioOptions,
|
|
435
|
+
): Promise<ExtractAudioResult>;
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Extract representative visual frames from a video.
|
|
439
|
+
*
|
|
440
|
+
* Frames are spread across the full usable video duration by default, or within
|
|
441
|
+
* the window defined by `startTimeMs` and `endBeforeMs`. Low-information frames
|
|
442
|
+
* are rejected, and perceptual hashing is used to avoid near-duplicates.
|
|
443
|
+
*/
|
|
444
|
+
export function sampleFrames(
|
|
445
|
+
videoPath: string,
|
|
446
|
+
options?: SampleFramesOptions,
|
|
447
|
+
): Promise<SampleFramesResult>;
|
package/src/lib.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { PassThrough } from 'stream';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
export const bundledFfmpegPath = require('@ffmpeg-installer/ffmpeg').path;
|
|
10
|
+
export const bundledFfprobePath = require('@ffprobe-installer/ffprobe').path;
|
|
11
|
+
export const ffmpeg = require('fluent-ffmpeg');
|
|
12
|
+
|
|
13
|
+
ffmpeg.setFfmpegPath(bundledFfmpegPath);
|
|
14
|
+
|
|
15
|
+
export const COMMON_INPUT_OPTS = [
|
|
16
|
+
'-analyzeduration',
|
|
17
|
+
'2147483647',
|
|
18
|
+
'-probesize',
|
|
19
|
+
'2147483647',
|
|
20
|
+
'-fflags',
|
|
21
|
+
'+genpts',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const resolveBinaryPaths = (options = {}) => ({
|
|
25
|
+
ffmpegPath: options.ffmpegPath || bundledFfmpegPath,
|
|
26
|
+
ffprobePath: options.ffprobePath || bundledFfprobePath,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const runCommand = (command, args) =>
|
|
30
|
+
new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(command, args, { windowsHide: true });
|
|
32
|
+
const stdoutChunks = [];
|
|
33
|
+
const stderrChunks = [];
|
|
34
|
+
|
|
35
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
36
|
+
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
|
|
37
|
+
child.on('error', (error) => {
|
|
38
|
+
reject(new Error(`Could not run ${command}: ${error.message}`));
|
|
39
|
+
});
|
|
40
|
+
child.on('close', (code) => {
|
|
41
|
+
const stdout = Buffer.concat(stdoutChunks).toString();
|
|
42
|
+
const stderr = Buffer.concat(stderrChunks).toString();
|
|
43
|
+
|
|
44
|
+
if (code === 0) {
|
|
45
|
+
resolve({ stdout, stderr });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
reject(new Error(`${path.basename(command)} exited ${code}: ${stderr.trim()}`));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const runFfmpegCLI = async (args, options = {}) => {
|
|
54
|
+
const binaries = resolveBinaryPaths(options);
|
|
55
|
+
await runCommand(binaries.ffmpegPath, args);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const runFfmpegPipeToFile = (inputPath, outputOpts, format, outputPath, inputOpts = []) =>
|
|
59
|
+
new Promise((resolve, reject) => {
|
|
60
|
+
const stdin = new PassThrough();
|
|
61
|
+
const inputStream = fs.createReadStream(inputPath);
|
|
62
|
+
const outStream = fs.createWriteStream(outputPath);
|
|
63
|
+
|
|
64
|
+
inputStream.on('error', reject);
|
|
65
|
+
outStream.on('error', reject);
|
|
66
|
+
outStream.on('finish', resolve);
|
|
67
|
+
inputStream.pipe(stdin);
|
|
68
|
+
|
|
69
|
+
ffmpeg()
|
|
70
|
+
.addInput(stdin)
|
|
71
|
+
.inputOptions(inputOpts)
|
|
72
|
+
.outputOptions(outputOpts)
|
|
73
|
+
.format(format)
|
|
74
|
+
.on('start', (cmd) => console.log('[ffmpeg start]', cmd))
|
|
75
|
+
.on('stderr', (line) => console.log('[ffmpeg]', line))
|
|
76
|
+
.on('error', reject)
|
|
77
|
+
.pipe(outStream, { end: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const runFfmpegPipe = (inputBuffer, outputOpts, format, inputOpts = []) =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
const stdin = new PassThrough();
|
|
83
|
+
const stdout = new PassThrough();
|
|
84
|
+
const chunks = [];
|
|
85
|
+
|
|
86
|
+
stdout.on('data', (chunk) => chunks.push(chunk));
|
|
87
|
+
stdout.on('end', () => resolve(Buffer.concat(chunks)));
|
|
88
|
+
stdout.on('error', reject);
|
|
89
|
+
stdin.end(inputBuffer);
|
|
90
|
+
|
|
91
|
+
ffmpeg()
|
|
92
|
+
.addInput(stdin)
|
|
93
|
+
.inputOptions(inputOpts)
|
|
94
|
+
.outputOptions(outputOpts)
|
|
95
|
+
.format(format)
|
|
96
|
+
.on('start', (cmd) => console.log('[ffmpeg start]', cmd))
|
|
97
|
+
.on('stderr', (line) => console.log('[ffmpeg]', line))
|
|
98
|
+
.on('error', reject)
|
|
99
|
+
.pipe(stdout, { end: true });
|
|
100
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
|
|
6
|
+
export { fs, os, path, uuidv4 };
|
|
7
|
+
|
|
8
|
+
export const createTempPath = (extension) =>
|
|
9
|
+
path.join(os.tmpdir(), `${uuidv4()}${extension}`);
|
|
10
|
+
|
|
11
|
+
export const deleteFileQuietly = async (filePath) => {
|
|
12
|
+
if (!filePath) return;
|
|
13
|
+
await fs.promises.unlink(filePath).catch(() => {});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const deleteFilesQuietly = async (filePaths) => {
|
|
17
|
+
await Promise.all(filePaths.filter(Boolean).map(deleteFileQuietly));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const moveFile = async (fromPath, toPath) => {
|
|
21
|
+
await fs.promises.mkdir(path.dirname(toPath), { recursive: true });
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await fs.promises.rename(fromPath, toPath);
|
|
25
|
+
} catch {
|
|
26
|
+
await fs.promises.copyFile(fromPath, toPath);
|
|
27
|
+
await deleteFileQuietly(fromPath);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const getAvailableFilePath = async (directory, fileName) => {
|
|
32
|
+
const parsed = path.parse(fileName);
|
|
33
|
+
let candidate = path.join(directory, fileName);
|
|
34
|
+
let suffix = 2;
|
|
35
|
+
|
|
36
|
+
while (await fileExists(candidate)) {
|
|
37
|
+
candidate = path.join(directory, `${parsed.name}-${suffix}${parsed.ext}`);
|
|
38
|
+
suffix += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return candidate;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const fileExists = async (filePath) => {
|
|
45
|
+
try {
|
|
46
|
+
await fs.promises.access(filePath);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const resolveOutputDirectory = async (outputDir, prefix) => {
|
|
54
|
+
if (outputDir) {
|
|
55
|
+
const directory = path.resolve(outputDir);
|
|
56
|
+
await fs.promises.mkdir(directory, { recursive: true });
|
|
57
|
+
return directory;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const assertFileExists = async (filePath, missingMessage, notFileMessage) => {
|
|
64
|
+
let stat;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
stat = await fs.promises.stat(filePath);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(missingMessage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
throw new Error(notFileMessage);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return stat;
|
|
77
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const firstFiniteNumber = (...values) => {
|
|
2
|
+
for (const value of values) {
|
|
3
|
+
const number = Number(value);
|
|
4
|
+
if (Number.isFinite(number)) return number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
return null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const positiveIntegerOrDefault = (value, fallback) => {
|
|
11
|
+
const number = Number(value);
|
|
12
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const toPositiveInteger = (value, fallback) => {
|
|
16
|
+
const number = Number(value);
|
|
17
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const finiteNumberOrDefault = (value, fallback) => {
|
|
21
|
+
const number = Number(value);
|
|
22
|
+
return Number.isFinite(number) ? number : fallback;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const average = (values) => {
|
|
26
|
+
if (values.length === 0) return 0;
|
|
27
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const roundTimestamp = (timestamp) => Number(timestamp.toFixed(3));
|
|
31
|
+
|
|
32
|
+
export const formatTimestamp = (timestamp) => Math.max(0, timestamp).toFixed(3);
|
|
33
|
+
|
|
34
|
+
export const parseFps = (frameRate) => {
|
|
35
|
+
if (!frameRate || typeof frameRate !== 'string') return null;
|
|
36
|
+
|
|
37
|
+
const [numerator, denominator] = frameRate.split('/').map(Number);
|
|
38
|
+
if (Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0) {
|
|
39
|
+
return numerator / denominator;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fps = Number(frameRate);
|
|
43
|
+
return Number.isFinite(fps) && fps > 0 ? fps : null;
|
|
44
|
+
};
|