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.
@@ -0,0 +1,144 @@
1
+ import {
2
+ COMMON_INPUT_OPTS,
3
+ runFfmpegCLI,
4
+ runFfmpegPipe,
5
+ runFfmpegPipeToFile,
6
+ } from '../shared/ffmpeg.js';
7
+ import {
8
+ createTempPath,
9
+ deleteFilesQuietly,
10
+ fs,
11
+ moveFile,
12
+ path,
13
+ } from '../shared/files.js';
14
+
15
+ const WAV_TOO_SMALL_MESSAGE = 'WAV too small - conversion failed';
16
+
17
+ export { COMMON_INPUT_OPTS, runFfmpegPipe, runFfmpegPipeToFile };
18
+
19
+ export const isMp4ByHeader = async (filePath) => {
20
+ const fileHandle = await fs.promises.open(filePath, 'r');
21
+
22
+ try {
23
+ const buffer = Buffer.alloc(8);
24
+ await fileHandle.read(buffer, 0, 8, 0);
25
+ return buffer.slice(4, 8).toString() === 'ftyp';
26
+ } finally {
27
+ await fileHandle.close();
28
+ }
29
+ };
30
+
31
+ export const extractAudio = async (videoPath, options = {}) => {
32
+ const outputPath = await resolveAudioOutputPath(videoPath, options);
33
+ const extracted = await extractAudioWavPath(videoPath, options);
34
+
35
+ await moveFile(extracted.wavPath, outputPath);
36
+
37
+ return {
38
+ path: outputPath,
39
+ mime: extracted.mime,
40
+ ext: extracted.ext,
41
+ };
42
+ };
43
+
44
+ export const extractAudioWavPath = async (videoPath, options = {}) => {
45
+ const tmpIn = createTempPath('.in');
46
+ await fs.promises.copyFile(videoPath, tmpIn);
47
+
48
+ const isMp4 = await isMp4ByHeader(tmpIn);
49
+ const tmpRemux = isMp4 ? createTempPath('.remux.mp4') : null;
50
+ const tmpOut = createTempPath('.wav');
51
+
52
+ try {
53
+ const wavPath = await convertTempInputToWavPath({
54
+ tmpIn,
55
+ tmpOut,
56
+ tmpRemux,
57
+ isMp4,
58
+ ffmpegPath: options.ffmpegPath,
59
+ });
60
+ return { wavPath, mime: 'audio/wav', ext: 'wav' };
61
+ } catch (error) {
62
+ await deleteFilesQuietly([tmpOut, tmpIn, tmpRemux]);
63
+ throw error;
64
+ } finally {
65
+ await deleteFilesQuietly([tmpIn, tmpRemux]);
66
+ }
67
+ };
68
+
69
+ const extractAudioBuffer = async (videoBuffer, options = {}) => {
70
+ const tmpIn = createTempPath('.in');
71
+ await fs.promises.writeFile(tmpIn, videoBuffer);
72
+
73
+ const isMp4 = videoBuffer.slice(4, 8).toString() === 'ftyp';
74
+ const tmpRemux = isMp4 ? createTempPath('.remux.mp4') : null;
75
+ const tmpOut = createTempPath('.wav');
76
+
77
+ try {
78
+ await convertTempInputToWavPath({
79
+ tmpIn,
80
+ tmpOut,
81
+ tmpRemux,
82
+ isMp4,
83
+ ffmpegPath: options.ffmpegPath,
84
+ });
85
+
86
+ const wav = await fs.promises.readFile(tmpOut);
87
+ if (wav.length < 1024) throw new Error(WAV_TOO_SMALL_MESSAGE);
88
+
89
+ return { buffer: wav, mime: 'audio/wav', ext: 'wav' };
90
+ } finally {
91
+ await deleteFilesQuietly([tmpIn, tmpRemux, tmpOut]);
92
+ }
93
+ };
94
+
95
+ const convertTempInputToWavPath = async ({ tmpIn, tmpOut, tmpRemux, isMp4, ffmpegPath }) => {
96
+ if (isMp4) {
97
+ await runFfmpegCLI(
98
+ ['-y', '-i', tmpIn, '-c', 'copy', '-movflags', '+faststart', tmpRemux],
99
+ { ffmpegPath },
100
+ );
101
+ }
102
+
103
+ const decodeInput = isMp4 ? tmpRemux : tmpIn;
104
+
105
+ await runFfmpegCLI(
106
+ [
107
+ '-y',
108
+ ...COMMON_INPUT_OPTS,
109
+ '-i',
110
+ decodeInput,
111
+ '-map',
112
+ '0:a:0',
113
+ '-vn',
114
+ '-c:a',
115
+ 'pcm_s16le',
116
+ '-ar',
117
+ '16000',
118
+ '-ac',
119
+ '1',
120
+ tmpOut,
121
+ ],
122
+ { ffmpegPath },
123
+ );
124
+
125
+ const stat = await fs.promises.stat(tmpOut);
126
+ if (stat.size < 1024) throw new Error(WAV_TOO_SMALL_MESSAGE);
127
+
128
+ return tmpOut;
129
+ };
130
+
131
+ const resolveAudioOutputPath = async (videoPath, options) => {
132
+ if (options.outputPath) {
133
+ const outputPath = path.resolve(options.outputPath);
134
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
135
+ return outputPath;
136
+ }
137
+
138
+ const outputDir = path.resolve(options.outputDir || process.cwd());
139
+ await fs.promises.mkdir(outputDir, { recursive: true });
140
+
141
+ return path.join(outputDir, `${path.parse(videoPath).name}.wav`);
142
+ };
143
+
144
+ export default extractAudioBuffer;
@@ -0,0 +1,102 @@
1
+ import {
2
+ DEFAULT_FRAME_OPTIONS,
3
+ applyFrameOption,
4
+ isBooleanOption,
5
+ } from './frameOptions.js';
6
+
7
+ export const parseArgs = (argv) => {
8
+ const args = {
9
+ videoPath: null,
10
+ framesDir: null,
11
+ audioDir: null,
12
+ ffmpegPath: null,
13
+ ffprobePath: null,
14
+ frameOptions: { ...DEFAULT_FRAME_OPTIONS },
15
+ help: false,
16
+ };
17
+
18
+ for (let index = 0; index < argv.length; index += 1) {
19
+ const arg = argv[index];
20
+
21
+ if (arg === '--help' || arg === '-h') {
22
+ args.help = true;
23
+ continue;
24
+ }
25
+
26
+ if (!arg.startsWith('--')) {
27
+ applyPositionalArg(args, arg);
28
+ continue;
29
+ }
30
+
31
+ const option = readOption(arg, argv[index + 1]);
32
+ if (option.consumedNext) index += 1;
33
+
34
+ applyOption(args, option.key, option.value);
35
+ }
36
+
37
+ return args;
38
+ };
39
+
40
+ const applyPositionalArg = (args, arg) => {
41
+ if (!args.videoPath) {
42
+ args.videoPath = arg;
43
+ return;
44
+ }
45
+
46
+ throw new Error(`Unexpected positional argument: ${arg}`);
47
+ };
48
+
49
+ const readOption = (arg, nextArg) => {
50
+ const equalsIndex = arg.indexOf('=');
51
+
52
+ if (equalsIndex !== -1) {
53
+ return {
54
+ key: arg.slice(2, equalsIndex),
55
+ value: arg.slice(equalsIndex + 1),
56
+ consumedNext: false,
57
+ };
58
+ }
59
+
60
+ const key = arg.slice(2);
61
+
62
+ if (isBooleanOption(key)) {
63
+ return { key, value: 'true', consumedNext: false };
64
+ }
65
+
66
+ if (!nextArg || nextArg.startsWith('--')) {
67
+ throw new Error(`Missing value for --${key}`);
68
+ }
69
+
70
+ return { key, value: nextArg, consumedNext: true };
71
+ };
72
+
73
+ const applyOption = (args, key, value) => {
74
+ switch (key) {
75
+ case 'video':
76
+ args.videoPath = value;
77
+ return;
78
+ case 'frames-dir':
79
+ case 'framesDir':
80
+ case 'output-dir':
81
+ case 'outputDir':
82
+ args.framesDir = value;
83
+ return;
84
+ case 'audio-dir':
85
+ case 'audioDir':
86
+ args.audioDir = value;
87
+ return;
88
+ case 'ffmpeg-path':
89
+ case 'ffmpegPath':
90
+ args.ffmpegPath = value;
91
+ args.frameOptions.ffmpegPath = value;
92
+ return;
93
+ case 'ffprobe-path':
94
+ case 'ffprobePath':
95
+ args.ffprobePath = value;
96
+ args.frameOptions.ffprobePath = value;
97
+ return;
98
+ default:
99
+ if (applyFrameOption(args.frameOptions, key, value)) return;
100
+ throw new Error(`Unknown option: --${key}`);
101
+ }
102
+ };
@@ -0,0 +1,121 @@
1
+ import { DEFAULT_FRAME_OPTIONS } from '../frames/defaults.js';
2
+
3
+ export { DEFAULT_FRAME_OPTIONS };
4
+
5
+ export const applyFrameOption = (frameOptions, key, value) => {
6
+ switch (key) {
7
+ case 'target-frames':
8
+ case 'targetFrames':
9
+ case 'frames':
10
+ frameOptions.targetFrames = parsePositiveIntegerOption(key, value);
11
+ return true;
12
+ case 'max-frames':
13
+ case 'maxFrames':
14
+ frameOptions.maxFrames = parsePositiveIntegerOption(key, value);
15
+ return true;
16
+ case 'min-frames':
17
+ case 'minFrames':
18
+ frameOptions.minFrames = parsePositiveIntegerOption(key, value);
19
+ return true;
20
+ case 'min-gap':
21
+ case 'min-gap-ms':
22
+ case 'min-gap-milliseconds':
23
+ case 'minGapMs':
24
+ frameOptions.minGapMs = parsePositiveNumberOption(key, value);
25
+ return true;
26
+ case 'min-gap-seconds':
27
+ case 'minGapSeconds':
28
+ frameOptions.minGapSeconds = parsePositiveNumberOption(key, value);
29
+ return true;
30
+ case 'start-time-ms':
31
+ case 'startTimeMs':
32
+ frameOptions.startTimeMs = parseNumberOption(key, value);
33
+ return true;
34
+ case 'end-before-ms':
35
+ case 'endBeforeMs':
36
+ frameOptions.endBeforeMs = parseNumberOption(key, value);
37
+ return true;
38
+ case 'candidate-multiplier':
39
+ case 'candidateMultiplier':
40
+ frameOptions.candidateMultiplier = parsePositiveIntegerOption(key, value);
41
+ return true;
42
+ case 'max-candidates':
43
+ case 'maxCandidates':
44
+ frameOptions.maxCandidates = parsePositiveIntegerOption(key, value);
45
+ return true;
46
+ case 'similarity-threshold':
47
+ case 'similarityThreshold':
48
+ frameOptions.similarityThreshold = parseNonNegativeIntegerOption(key, value);
49
+ return true;
50
+ case 'compare-recent':
51
+ case 'compare-recent-count':
52
+ case 'compareRecentCount':
53
+ frameOptions.compareRecentCount = parsePositiveIntegerOption(key, value);
54
+ return true;
55
+ case 'format':
56
+ case 'image-format':
57
+ case 'imageFormat':
58
+ frameOptions.imageFormat = value;
59
+ return true;
60
+ case 'jpeg-quality':
61
+ case 'jpegQuality':
62
+ frameOptions.jpegQuality = parsePositiveIntegerOption(key, value);
63
+ return true;
64
+ case 'debug':
65
+ frameOptions.debug = parseBooleanOption(key, value);
66
+ return true;
67
+ case 'no-fallback':
68
+ frameOptions.includeFallbackFrames = false;
69
+ return true;
70
+ case 'keep-rejected':
71
+ frameOptions.cleanupRejected = false;
72
+ return true;
73
+ default:
74
+ return false;
75
+ }
76
+ };
77
+
78
+ export const isBooleanOption = (key) =>
79
+ ['debug', 'no-fallback', 'keep-rejected'].includes(key);
80
+
81
+ const parsePositiveIntegerOption = (key, value) => {
82
+ const number = Number(value);
83
+ if (!Number.isInteger(number) || number <= 0) {
84
+ throw new Error(`--${key} must be a positive integer.`);
85
+ }
86
+
87
+ return number;
88
+ };
89
+
90
+ const parseNonNegativeIntegerOption = (key, value) => {
91
+ const number = Number(value);
92
+ if (!Number.isInteger(number) || number < 0) {
93
+ throw new Error(`--${key} must be a non-negative integer.`);
94
+ }
95
+
96
+ return number;
97
+ };
98
+
99
+ const parseNumberOption = (key, value) => {
100
+ const number = Number(value);
101
+ if (!Number.isFinite(number)) {
102
+ throw new Error(`--${key} must be a number.`);
103
+ }
104
+
105
+ return number;
106
+ };
107
+
108
+ const parsePositiveNumberOption = (key, value) => {
109
+ const number = Number(value);
110
+ if (!Number.isFinite(number) || number <= 0) {
111
+ throw new Error(`--${key} must be a positive number.`);
112
+ }
113
+
114
+ return number;
115
+ };
116
+
117
+ const parseBooleanOption = (key, value) => {
118
+ if (value === true || value === 'true') return true;
119
+ if (value === false || value === 'false') return false;
120
+ throw new Error(`--${key} must be true or false.`);
121
+ };
@@ -0,0 +1,46 @@
1
+ import { parseArgs } from './args.js';
2
+ import { printSummary, printUsage } from './print.js';
3
+ import { processAudio, processFrames } from './processors.js';
4
+ import { resolveVideoPath } from './videoFiles.js';
5
+ import { fs, path } from '../shared/files.js';
6
+
7
+ export const main = async (argv = process.argv.slice(2), cwd = process.cwd()) => {
8
+ const args = parseArgs(argv);
9
+
10
+ if (args.help) {
11
+ printUsage();
12
+ return;
13
+ }
14
+
15
+ const videoPath = await resolveVideoPath(args.videoPath, cwd);
16
+ const framesDir = path.resolve(cwd, args.framesDir || 'frames');
17
+ const audioDir = path.resolve(cwd, args.audioDir || 'audio');
18
+
19
+ await fs.promises.mkdir(framesDir, { recursive: true });
20
+ await fs.promises.mkdir(audioDir, { recursive: true });
21
+
22
+ console.log('Input video:', videoPath);
23
+ console.log('Frames directory:', framesDir);
24
+ console.log('Audio directory:', audioDir);
25
+
26
+ const audio = await processAudio(videoPath, audioDir, {
27
+ ffmpegPath: args.ffmpegPath,
28
+ });
29
+ const frameResult = await processFrames(videoPath, framesDir, args.frameOptions);
30
+ const result = buildResult(frameResult, audio, audioDir);
31
+
32
+ printSummary(result);
33
+ };
34
+
35
+ const buildResult = (frameResult, audio, audioDir) => ({
36
+ video: frameResult.video,
37
+ audio,
38
+ frames: frameResult.frames,
39
+ output: {
40
+ audioDirectory: audioDir,
41
+ framesDirectory: frameResult.output.directory,
42
+ },
43
+ options: frameResult.options,
44
+ stats: frameResult.stats,
45
+ warnings: frameResult.warnings,
46
+ });
@@ -0,0 +1,56 @@
1
+ export const printSummary = (result) => {
2
+ console.log('');
3
+ console.log('Processing complete.');
4
+ console.log('Duration seconds:', result.video.durationSeconds);
5
+ console.log('Audio file:', result.audio.path);
6
+ console.log('Frames returned:', result.frames.length);
7
+ console.log('Frame timestamps:', result.frames.map((frame) => frame.timestamp));
8
+ console.log('Frame paths:');
9
+
10
+ for (const frame of result.frames) {
11
+ console.log(`- ${frame.path}`);
12
+ }
13
+
14
+ if (result.warnings.length > 0) {
15
+ console.log('Warnings:');
16
+ for (const warning of result.warnings) {
17
+ console.log(`- ${warning}`);
18
+ }
19
+ }
20
+
21
+ console.log('Stats:', result.stats);
22
+ };
23
+
24
+ export const printUsage = () => {
25
+ console.log(`
26
+ Usage:
27
+ video-sampler [videoPath] [options]
28
+ video-sampler --video "./interview.webm" --frames 12 --max-frames 24
29
+
30
+ If videoPath or --video is omitted, the CLI uses the only supported video file in the project root.
31
+
32
+ Options:
33
+ --video <path> Video file to process
34
+ --frames <n> Target number of frames
35
+ --target-frames <n> Same as --frames
36
+ --max-frames <n> Maximum returned frames
37
+ --min-frames <n> Minimum frames to try to return
38
+ --min-gap <ms> Soft minimum gap between selected frames, default 1000
39
+ --min-gap-ms <ms> Same as --min-gap
40
+ --start-time-ms <ms> Start sampling at this timestamp, default 0
41
+ --end-before-ms <ms> Stop sampling this many ms before the video ends
42
+ --candidate-multiplier <n> Candidate count multiplier
43
+ --max-candidates <n> Maximum candidate frames to inspect
44
+ --similarity-threshold <n> pHash Hamming distance threshold
45
+ --compare-recent <n> Recent accepted frames to compare after early selection
46
+ --frames-dir <path> Output folder for accepted frames, default ./frames
47
+ --audio-dir <path> Output folder for WAV audio, default ./audio
48
+ --ffmpeg-path <path> Custom FFmpeg binary path
49
+ --ffprobe-path <path> Custom FFprobe binary path
50
+ --format <jpg|png|webp> Frame image format, default jpg
51
+ --jpeg-quality <n> FFmpeg q:v value for JPG, lower is better
52
+ --debug Print FFmpeg debug output from frame extraction
53
+ --no-fallback Disable fallback frame coverage
54
+ --keep-rejected Keep rejected candidate frame files
55
+ `);
56
+ };
@@ -0,0 +1,14 @@
1
+ import { extractAudio } from '../audio/extractAudioBuffer.js';
2
+ import { sampleFrames } from '../frames/frameSampler.js';
3
+
4
+ export const processAudio = async (videoPath, audioDir, options = {}) =>
5
+ extractAudio(videoPath, {
6
+ outputDir: audioDir,
7
+ ffmpegPath: options.ffmpegPath,
8
+ });
9
+
10
+ export const processFrames = async (videoPath, framesDir, frameOptions) =>
11
+ sampleFrames(videoPath, {
12
+ ...frameOptions,
13
+ outputDir: framesDir,
14
+ });
@@ -0,0 +1,48 @@
1
+ import { fs, path } from '../shared/files.js';
2
+
3
+ const VIDEO_EXTENSIONS = new Set([
4
+ '.mp4',
5
+ '.mov',
6
+ '.m4v',
7
+ '.webm',
8
+ '.mkv',
9
+ '.avi',
10
+ ]);
11
+
12
+ export const resolveVideoPath = async (videoPath, cwd) => {
13
+ const resolved = videoPath
14
+ ? path.resolve(cwd, videoPath)
15
+ : await findSingleVideoInDirectory(cwd);
16
+
17
+ let stat;
18
+ try {
19
+ stat = await fs.promises.stat(resolved);
20
+ } catch {
21
+ throw new Error(`Video file does not exist: ${resolved}`);
22
+ }
23
+
24
+ if (!stat.isFile()) {
25
+ throw new Error(`Video path is not a file: ${resolved}`);
26
+ }
27
+
28
+ return resolved;
29
+ };
30
+
31
+ const findSingleVideoInDirectory = async (directory) => {
32
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
33
+ const videos = entries
34
+ .filter((entry) => entry.isFile() && VIDEO_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
35
+ .map((entry) => path.join(directory, entry.name));
36
+
37
+ if (videos.length === 0) {
38
+ throw new Error('No video path was provided and no supported video file was found in this directory.');
39
+ }
40
+
41
+ if (videos.length > 1) {
42
+ throw new Error(
43
+ `Multiple video files were found. Pass one explicitly with --video. Found: ${videos.map((file) => path.basename(file)).join(', ')}`,
44
+ );
45
+ }
46
+
47
+ return videos[0];
48
+ };
@@ -0,0 +1,19 @@
1
+ import { deleteFileQuietly } from '../shared/files.js';
2
+
3
+ export const cleanupRejectedCandidates = async (candidates, acceptedPaths, cleanupRejected) => {
4
+ if (!cleanupRejected) return;
5
+
6
+ await Promise.all(
7
+ candidates
8
+ .filter((candidate) => !acceptedPaths.has(candidate.path))
9
+ .map((candidate) => deleteFileQuietly(candidate.path)),
10
+ );
11
+ };
12
+
13
+ export const cleanupErroredFrames = async (createdFramePaths, acceptedFramePaths) => {
14
+ await Promise.all(
15
+ [...createdFramePaths]
16
+ .filter((framePath) => !acceptedFramePaths.has(framePath))
17
+ .map(deleteFileQuietly),
18
+ );
19
+ };
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_FRAME_OPTIONS = {
2
+ targetFrames: 12,
3
+ maxFrames: 24,
4
+ minFrames: 3,
5
+ minGapMs: 1000,
6
+ startTimeMs: 0,
7
+ endBeforeMs: 0,
8
+ candidateMultiplier: 4,
9
+ maxCandidates: 160,
10
+ similarityThreshold: 10,
11
+ compareRecentCount: 5,
12
+ outputDir: null,
13
+ imageFormat: 'jpg',
14
+ jpegQuality: 2,
15
+ cleanupRejected: true,
16
+ includeFallbackFrames: true,
17
+ debug: false,
18
+ };
19
+
20
+ export const MIN_FRAME_BYTES = 100;