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,221 @@
1
+ import {
2
+ finiteNumberOrDefault,
3
+ positiveIntegerOrDefault,
4
+ } from '../shared/numbers.js';
5
+ import { DEFAULT_FRAME_OPTIONS } from './defaults.js';
6
+
7
+ export const resolveOptions = (options, warnings) => {
8
+ const merged = { ...DEFAULT_FRAME_OPTIONS, ...options };
9
+
10
+ const maxFrames = positiveIntegerOrDefault(merged.maxFrames, DEFAULT_FRAME_OPTIONS.maxFrames);
11
+ const targetFrames = positiveIntegerOrDefault(merged.targetFrames, DEFAULT_FRAME_OPTIONS.targetFrames);
12
+ const minFrames = Math.min(
13
+ positiveIntegerOrDefault(merged.minFrames, DEFAULT_FRAME_OPTIONS.minFrames),
14
+ maxFrames,
15
+ );
16
+
17
+ const minGapMs = resolveMinGapMs(merged, options, warnings);
18
+ const minGapSeconds = minGapMs / 1000;
19
+ const startTimeMs = resolveStartTimeMs(merged.startTimeMs, warnings);
20
+ const endBeforeMs = resolveEndBeforeMs(merged.endBeforeMs, warnings);
21
+
22
+ let imageFormat = String(merged.imageFormat || DEFAULT_FRAME_OPTIONS.imageFormat).toLowerCase();
23
+ if (imageFormat === 'jpeg') imageFormat = 'jpg';
24
+
25
+ if (!['jpg', 'png', 'webp'].includes(imageFormat)) {
26
+ warnings.push(`Unsupported imageFormat "${merged.imageFormat}" was replaced with jpg.`);
27
+ imageFormat = 'jpg';
28
+ }
29
+
30
+ return {
31
+ targetFrames,
32
+ maxFrames,
33
+ minFrames,
34
+ minGapMs,
35
+ minGapSeconds,
36
+ startTimeMs,
37
+ endBeforeMs,
38
+ candidateMultiplier: Math.max(
39
+ 1,
40
+ positiveIntegerOrDefault(
41
+ merged.candidateMultiplier,
42
+ DEFAULT_FRAME_OPTIONS.candidateMultiplier,
43
+ ),
44
+ ),
45
+ maxCandidates: Math.max(
46
+ 1,
47
+ positiveIntegerOrDefault(merged.maxCandidates, DEFAULT_FRAME_OPTIONS.maxCandidates),
48
+ ),
49
+ similarityThreshold: Math.max(
50
+ 0,
51
+ positiveIntegerOrDefault(
52
+ merged.similarityThreshold,
53
+ DEFAULT_FRAME_OPTIONS.similarityThreshold,
54
+ ),
55
+ ),
56
+ compareRecentCount: Math.max(
57
+ 1,
58
+ positiveIntegerOrDefault(
59
+ merged.compareRecentCount,
60
+ DEFAULT_FRAME_OPTIONS.compareRecentCount,
61
+ ),
62
+ ),
63
+ outputDir: merged.outputDir,
64
+ ffmpegPath: merged.ffmpegPath,
65
+ ffprobePath: merged.ffprobePath,
66
+ imageFormat,
67
+ jpegQuality: positiveIntegerOrDefault(
68
+ merged.jpegQuality,
69
+ DEFAULT_FRAME_OPTIONS.jpegQuality,
70
+ ),
71
+ cleanupRejected: merged.cleanupRejected !== false,
72
+ includeFallbackFrames: merged.includeFallbackFrames !== false,
73
+ debug: Boolean(merged.debug),
74
+ };
75
+ };
76
+
77
+ const resolveStartTimeMs = (value, warnings) => {
78
+ let startTimeMs = finiteNumberOrDefault(value, DEFAULT_FRAME_OPTIONS.startTimeMs);
79
+
80
+ if (value !== undefined && !Number.isFinite(Number(value))) {
81
+ warnings.push(`Invalid startTimeMs "${value}" was replaced with 0.`);
82
+ startTimeMs = 0;
83
+ }
84
+
85
+ if (startTimeMs < 0) {
86
+ warnings.push(`Invalid startTimeMs "${value}" was replaced with 0.`);
87
+ startTimeMs = 0;
88
+ }
89
+
90
+ return startTimeMs;
91
+ };
92
+
93
+ const resolveEndBeforeMs = (value, warnings) => {
94
+ let endBeforeMs = finiteNumberOrDefault(value, DEFAULT_FRAME_OPTIONS.endBeforeMs);
95
+
96
+ if (value !== undefined && !Number.isFinite(Number(value))) {
97
+ warnings.push(`Invalid endBeforeMs "${value}" was replaced with 0.`);
98
+ endBeforeMs = 0;
99
+ }
100
+
101
+ if (endBeforeMs < 0) {
102
+ warnings.push(`Invalid endBeforeMs "${value}" was replaced with 0.`);
103
+ endBeforeMs = 0;
104
+ }
105
+
106
+ return endBeforeMs;
107
+ };
108
+
109
+ const resolveMinGapMs = (options, rawOptions, warnings) => {
110
+ if (rawOptions.minGapSeconds !== undefined && rawOptions.minGapMs === undefined) {
111
+ warnings.push(
112
+ 'minGapSeconds is deprecated. Use minGapMs instead.',
113
+ );
114
+ return validateMinGapMs(options.minGapSeconds * 1000, options.minGapSeconds, warnings);
115
+ }
116
+
117
+ return validateMinGapMs(options.minGapMs, options.minGapMs, warnings);
118
+ };
119
+
120
+ const validateMinGapMs = (value, originalValue, warnings) => {
121
+ let minGapMs = finiteNumberOrDefault(value, DEFAULT_FRAME_OPTIONS.minGapMs);
122
+
123
+ if (minGapMs <= 0) {
124
+ warnings.push(
125
+ `Invalid minGapMs "${originalValue}" was replaced with ${DEFAULT_FRAME_OPTIONS.minGapMs}.`,
126
+ );
127
+ minGapMs = DEFAULT_FRAME_OPTIONS.minGapMs;
128
+ }
129
+
130
+ return minGapMs;
131
+ };
132
+
133
+ export const resolveSamplingWindow = (durationSeconds, options, warnings) => {
134
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
135
+ throw new Error(`Invalid video duration: ${durationSeconds}`);
136
+ }
137
+
138
+ const durationMs = durationSeconds * 1000;
139
+ let startTimeMs = Math.min(options.startTimeMs, durationMs);
140
+ let endBeforeMs = options.endBeforeMs;
141
+ let endTimeMs = durationMs - endBeforeMs;
142
+ let trimEndForEof = endBeforeMs === 0;
143
+
144
+ if (options.startTimeMs >= durationMs) {
145
+ warnings.push(
146
+ `startTimeMs "${options.startTimeMs}" is beyond the video duration; using the full video instead.`,
147
+ );
148
+ startTimeMs = 0;
149
+ endTimeMs = durationMs;
150
+ endBeforeMs = 0;
151
+ trimEndForEof = true;
152
+ } else if (endTimeMs <= startTimeMs) {
153
+ warnings.push(
154
+ `endBeforeMs "${options.endBeforeMs}" leaves no sampling window after startTimeMs "${options.startTimeMs}"; using the full video instead.`,
155
+ );
156
+ startTimeMs = 0;
157
+ endTimeMs = durationMs;
158
+ endBeforeMs = 0;
159
+ trimEndForEof = true;
160
+ }
161
+
162
+ return {
163
+ startTimeMs,
164
+ endTimeMs,
165
+ endBeforeMs,
166
+ trimEndForEof,
167
+ startSeconds: startTimeMs / 1000,
168
+ endSeconds: endTimeMs / 1000,
169
+ durationSeconds: Math.max(0.001, (endTimeMs - startTimeMs) / 1000),
170
+ videoDurationMs: durationMs,
171
+ };
172
+ };
173
+
174
+ export const resolveSamplingCounts = (durationSeconds, options) => {
175
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
176
+ throw new Error(`Invalid sampling duration: ${durationSeconds}`);
177
+ }
178
+
179
+ const theoreticalMaxByGap = Math.max(
180
+ 1,
181
+ Math.floor(durationSeconds / options.minGapSeconds),
182
+ );
183
+ const absoluteMaxByDuration = Math.max(
184
+ 1,
185
+ Math.min(options.maxFrames, Math.floor(durationSeconds * 2)),
186
+ );
187
+
188
+ const primaryTargetFrames = Math.max(
189
+ 1,
190
+ Math.min(options.targetFrames, options.maxFrames, theoreticalMaxByGap),
191
+ );
192
+ const minimumCoverageFrames = Math.max(
193
+ 1,
194
+ Math.min(options.minFrames, options.maxFrames, absoluteMaxByDuration),
195
+ );
196
+ const resolvedTargetFrames = Math.max(
197
+ 1,
198
+ Math.min(
199
+ options.maxFrames,
200
+ absoluteMaxByDuration,
201
+ Math.max(primaryTargetFrames, minimumCoverageFrames),
202
+ ),
203
+ );
204
+
205
+ const resolvedMinFrames = Math.max(
206
+ 1,
207
+ Math.min(options.minFrames, resolvedTargetFrames),
208
+ );
209
+
210
+ const candidateCount = Math.min(
211
+ options.maxCandidates,
212
+ Math.max(resolvedTargetFrames * options.candidateMultiplier, resolvedTargetFrames),
213
+ );
214
+
215
+ return {
216
+ theoreticalMaxByGap,
217
+ resolvedTargetFrames,
218
+ resolvedMinFrames,
219
+ candidateCount,
220
+ };
221
+ };
@@ -0,0 +1,60 @@
1
+ import { resolveBinaryPaths, runCommand } from '../shared/ffmpeg.js';
2
+ import { assertFileExists } from '../shared/files.js';
3
+ import {
4
+ firstFiniteNumber,
5
+ parseFps,
6
+ toPositiveInteger,
7
+ } from '../shared/numbers.js';
8
+
9
+ export const probeVideo = async (videoPath, options = {}) => {
10
+ if (!videoPath || typeof videoPath !== 'string') {
11
+ throw new Error('Input video path is required.');
12
+ }
13
+
14
+ await assertFileExists(
15
+ videoPath,
16
+ `Input video file does not exist: ${videoPath}`,
17
+ `Input video path is not a file: ${videoPath}`,
18
+ );
19
+
20
+ const binaries = resolveBinaryPaths(options);
21
+ const { stdout, stderr } = await runCommand(binaries.ffprobePath, [
22
+ '-v',
23
+ 'error',
24
+ '-print_format',
25
+ 'json',
26
+ '-show_format',
27
+ '-show_streams',
28
+ videoPath,
29
+ ]);
30
+
31
+ const metadata = parseProbeMetadata(stdout, videoPath);
32
+ const videoStream = Array.isArray(metadata.streams)
33
+ ? metadata.streams.find((stream) => stream.codec_type === 'video')
34
+ : null;
35
+
36
+ const durationSeconds = firstFiniteNumber(
37
+ videoStream?.duration,
38
+ metadata.format?.duration,
39
+ );
40
+
41
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
42
+ const detail = stderr ? ` ffprobe stderr: ${stderr.trim()}` : '';
43
+ throw new Error(`Video duration could not be determined for ${videoPath}.${detail}`);
44
+ }
45
+
46
+ return {
47
+ durationSeconds,
48
+ width: toPositiveInteger(videoStream?.width, null),
49
+ height: toPositiveInteger(videoStream?.height, null),
50
+ fps: parseFps(videoStream?.avg_frame_rate || videoStream?.r_frame_rate),
51
+ };
52
+ };
53
+
54
+ const parseProbeMetadata = (stdout, videoPath) => {
55
+ try {
56
+ return JSON.parse(stdout);
57
+ } catch (error) {
58
+ throw new Error(`Could not parse ffprobe metadata for ${videoPath}: ${error.message}`);
59
+ }
60
+ };
@@ -0,0 +1,160 @@
1
+ import { getImageHash, hammingDistance } from './hash.js';
2
+
3
+ export const selectRepresentativeFrames = async (candidates, sampling, options, warnings) => {
4
+ const accepted = [];
5
+ let candidatesRejectedAsSimilar = 0;
6
+ let candidatesRejectedForSpacing = 0;
7
+ let hashesAttempted = 0;
8
+ let hashesSucceeded = 0;
9
+
10
+ for (const candidate of candidates) {
11
+ if (accepted.length >= sampling.resolvedTargetFrames) break;
12
+
13
+ if (accepted.length > 0 && violatesMinGap(candidate, accepted, options.minGapSeconds)) {
14
+ candidatesRejectedForSpacing += 1;
15
+ continue;
16
+ }
17
+
18
+ hashesAttempted += 1;
19
+
20
+ try {
21
+ candidate.hash = await getImageHash(candidate.path);
22
+ hashesSucceeded += 1;
23
+ } catch (error) {
24
+ warnings.push(`Could not hash candidate frame ${candidate.path}: ${error.message}`);
25
+ continue;
26
+ }
27
+
28
+ if (accepted.length === 0) {
29
+ accepted.push(toAcceptedFrame(candidate, null, 'first_frame'));
30
+ continue;
31
+ }
32
+
33
+ const minDistance = getMinimumDistanceToAccepted(
34
+ candidate.hash,
35
+ accepted,
36
+ options.compareRecentCount,
37
+ );
38
+
39
+ if (minDistance >= options.similarityThreshold) {
40
+ accepted.push(toAcceptedFrame(candidate, minDistance, 'visually_distinct'));
41
+ } else {
42
+ candidatesRejectedAsSimilar += 1;
43
+ }
44
+ }
45
+
46
+ return {
47
+ accepted,
48
+ candidatesRejectedAsSimilar,
49
+ candidatesRejectedForSpacing,
50
+ hashesAttempted,
51
+ hashesSucceeded,
52
+ };
53
+ };
54
+
55
+ export const addFallbackFrames = async ({ accepted, candidates, sampling, resolved, warnings }) => {
56
+ const selected = [...accepted];
57
+ const acceptedPaths = new Set(selected.map((frame) => frame.path));
58
+ const targetCount = Math.min(sampling.resolvedTargetFrames, resolved.maxFrames);
59
+ const needed = Math.min(sampling.resolvedMinFrames, targetCount);
60
+ let added = 0;
61
+
62
+ for (const minGapSeconds of getFallbackGapAttempts(resolved.minGapSeconds)) {
63
+ if (selected.length >= needed || selected.length >= targetCount) break;
64
+
65
+ const pool = candidates.filter((candidate) => !acceptedPaths.has(candidate.path));
66
+ const ordered = orderCandidatesForEvenCoverage(pool, selected, targetCount);
67
+
68
+ for (const candidate of ordered) {
69
+ if (selected.length >= needed || selected.length >= targetCount) break;
70
+ if (minGapSeconds > 0 && violatesMinGap(candidate, selected, minGapSeconds)) continue;
71
+
72
+ const fallbackFrame = await buildFallbackFrame(candidate, selected, resolved, warnings);
73
+ if (!fallbackFrame) continue;
74
+
75
+ selected.push(fallbackFrame);
76
+ acceptedPaths.add(candidate.path);
77
+ added += 1;
78
+ }
79
+ }
80
+
81
+ return { accepted: selected, added };
82
+ };
83
+
84
+ export const toPublicFrame = (frame) => ({
85
+ path: frame.path,
86
+ timestamp: frame.timestamp,
87
+ hash: frame.hash,
88
+ score: {
89
+ minDistanceToRecentAccepted: frame.score.minDistanceToRecentAccepted,
90
+ acceptedReason: frame.score.acceptedReason,
91
+ },
92
+ });
93
+
94
+ export const violatesMinGap = (candidate, accepted, minGapSeconds) =>
95
+ accepted.some((frame) => Math.abs(candidate.timestamp - frame.timestamp) < minGapSeconds);
96
+
97
+ const buildFallbackFrame = async (candidate, selected, resolved, warnings) => {
98
+ try {
99
+ if (!candidate.hash) candidate.hash = await getImageHash(candidate.path);
100
+ } catch (error) {
101
+ warnings.push(`Could not hash fallback frame ${candidate.path}: ${error.message}`);
102
+ return null;
103
+ }
104
+
105
+ const minDistance = selected.length > 0
106
+ ? getMinimumDistanceToAccepted(candidate.hash, selected, resolved.compareRecentCount)
107
+ : null;
108
+
109
+ return toAcceptedFrame(candidate, minDistance, 'fallback_even_coverage');
110
+ };
111
+
112
+ const toAcceptedFrame = (candidate, minDistanceToRecentAccepted, acceptedReason) => ({
113
+ path: candidate.path,
114
+ timestamp: candidate.timestamp,
115
+ hash: candidate.hash,
116
+ score: {
117
+ minDistanceToRecentAccepted,
118
+ acceptedReason,
119
+ },
120
+ });
121
+
122
+ const getMinimumDistanceToAccepted = (hash, accepted, compareRecentCount) => {
123
+ const comparisonSet = accepted.length <= compareRecentCount
124
+ ? accepted
125
+ : accepted.slice(-compareRecentCount);
126
+
127
+ return Math.min(...comparisonSet.map((frame) => hammingDistance(hash, frame.hash)));
128
+ };
129
+
130
+ const getFallbackGapAttempts = (minGapSeconds) => [
131
+ minGapSeconds,
132
+ minGapSeconds / 2,
133
+ minGapSeconds / 4,
134
+ 0,
135
+ ];
136
+
137
+ const orderCandidatesForEvenCoverage = (candidates, accepted, targetCount) => {
138
+ if (candidates.length <= 1) return candidates;
139
+
140
+ const acceptedTimestamps = accepted.map((frame) => frame.timestamp);
141
+
142
+ return [...candidates].sort((a, b) => {
143
+ const aDistance = distanceFromNearestTimestamp(a.timestamp, acceptedTimestamps);
144
+ const bDistance = distanceFromNearestTimestamp(b.timestamp, acceptedTimestamps);
145
+
146
+ if (bDistance !== aDistance) return bDistance - aDistance;
147
+
148
+ const aBucket = Math.floor((a.index / Math.max(1, candidates.length - 1)) * targetCount);
149
+ const bBucket = Math.floor((b.index / Math.max(1, candidates.length - 1)) * targetCount);
150
+
151
+ if (aBucket !== bBucket) return aBucket - bBucket;
152
+ return a.timestamp - b.timestamp;
153
+ });
154
+ };
155
+
156
+ const distanceFromNearestTimestamp = (timestamp, timestamps) => {
157
+ if (timestamps.length === 0) return Number.POSITIVE_INFINITY;
158
+
159
+ return Math.min(...timestamps.map((otherTimestamp) => Math.abs(timestamp - otherTimestamp)));
160
+ };
@@ -0,0 +1,33 @@
1
+ import { roundTimestamp } from '../shared/numbers.js';
2
+
3
+ const EOF_MARGIN_MS = 50;
4
+ const VERY_SHORT_WINDOW_MS = 100;
5
+
6
+ export const generateCandidateTimestamps = (window, candidateCount) => {
7
+ const count = Math.max(1, Math.floor(candidateCount));
8
+ const startMs = Math.max(0, Math.round(window.startTimeMs));
9
+ const endMs = Math.max(startMs, Math.round(window.endTimeMs));
10
+ const windowDurationMs = endMs - startMs;
11
+
12
+ if (windowDurationMs <= VERY_SHORT_WINDOW_MS || count <= 1) {
13
+ return [roundTimestamp((startMs + windowDurationMs / 2) / 1000)];
14
+ }
15
+
16
+ const usableEndMs = Math.max(
17
+ startMs,
18
+ window.trimEndForEof ? endMs - EOF_MARGIN_MS : endMs,
19
+ );
20
+
21
+ if (usableEndMs <= startMs) {
22
+ return [roundTimestamp((startMs + windowDurationMs / 2) / 1000)];
23
+ }
24
+
25
+ const interval = (usableEndMs - startMs) / (count - 1);
26
+ const timestamps = [];
27
+
28
+ for (let index = 0; index < count; index += 1) {
29
+ timestamps.push(roundTimestamp((startMs + interval * index) / 1000));
30
+ }
31
+
32
+ return timestamps;
33
+ };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from './cli/main.js';
4
+
5
+ main().catch((error) => {
6
+ console.error(error.message || error);
7
+ process.exit(1);
8
+ });