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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { resolveBinaryPaths, runCommand } from '../shared/ffmpeg.js';
|
|
3
|
+
import {
|
|
4
|
+
deleteFileQuietly,
|
|
5
|
+
fs,
|
|
6
|
+
getAvailableFilePath,
|
|
7
|
+
path,
|
|
8
|
+
uuidv4,
|
|
9
|
+
} from '../shared/files.js';
|
|
10
|
+
import { formatTimestamp } from '../shared/numbers.js';
|
|
11
|
+
import { MIN_FRAME_BYTES } from './defaults.js';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const sharp = require('sharp');
|
|
15
|
+
|
|
16
|
+
sharp.cache(false);
|
|
17
|
+
|
|
18
|
+
export const extractCandidateFrames = async ({
|
|
19
|
+
videoPath,
|
|
20
|
+
timestamps,
|
|
21
|
+
outputDirectory,
|
|
22
|
+
imageFormat,
|
|
23
|
+
jpegQuality,
|
|
24
|
+
debug,
|
|
25
|
+
warnings,
|
|
26
|
+
createdFramePaths,
|
|
27
|
+
ffmpegPath,
|
|
28
|
+
videoDurationMs,
|
|
29
|
+
}) => {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
const timestampWarnings = [];
|
|
32
|
+
const naming = createCandidateNaming(timestamps.length, videoDurationMs);
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < timestamps.length; index += 1) {
|
|
35
|
+
const candidate = await extractCandidateFrame({
|
|
36
|
+
videoPath,
|
|
37
|
+
timestamp: timestamps[index],
|
|
38
|
+
outputDirectory,
|
|
39
|
+
imageFormat,
|
|
40
|
+
jpegQuality,
|
|
41
|
+
index,
|
|
42
|
+
debug,
|
|
43
|
+
warnings: timestampWarnings,
|
|
44
|
+
createdFramePaths,
|
|
45
|
+
ffmpegPath,
|
|
46
|
+
naming,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (candidate) candidates.push(candidate);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (candidates.length > 0) {
|
|
53
|
+
if (timestampWarnings.length > 0) {
|
|
54
|
+
warnings.push(
|
|
55
|
+
`Timestamp-based extraction skipped ${timestampWarnings.length} candidate frames that produced no image.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return candidates;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
warnings.push(
|
|
63
|
+
`Timestamp-based frame extraction failed for all ${timestamps.length} candidates; retrying with sequential FPS extraction.`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return extractSequentialCandidateFrames({
|
|
67
|
+
videoPath,
|
|
68
|
+
timestamps,
|
|
69
|
+
outputDirectory,
|
|
70
|
+
imageFormat,
|
|
71
|
+
jpegQuality,
|
|
72
|
+
debug,
|
|
73
|
+
warnings,
|
|
74
|
+
createdFramePaths,
|
|
75
|
+
ffmpegPath,
|
|
76
|
+
naming,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const extractCandidateFrame = async ({
|
|
81
|
+
videoPath,
|
|
82
|
+
timestamp,
|
|
83
|
+
outputDirectory,
|
|
84
|
+
imageFormat,
|
|
85
|
+
jpegQuality,
|
|
86
|
+
index,
|
|
87
|
+
debug,
|
|
88
|
+
warnings,
|
|
89
|
+
createdFramePaths,
|
|
90
|
+
ffmpegPath,
|
|
91
|
+
naming,
|
|
92
|
+
}) => {
|
|
93
|
+
const outputPath = await getAvailableCandidatePath(
|
|
94
|
+
outputDirectory,
|
|
95
|
+
index,
|
|
96
|
+
timestamp,
|
|
97
|
+
imageFormat,
|
|
98
|
+
naming,
|
|
99
|
+
);
|
|
100
|
+
createdFramePaths.add(outputPath);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await extractFrame(
|
|
104
|
+
videoPath,
|
|
105
|
+
timestamp,
|
|
106
|
+
outputPath,
|
|
107
|
+
imageFormat,
|
|
108
|
+
jpegQuality,
|
|
109
|
+
true,
|
|
110
|
+
debug,
|
|
111
|
+
ffmpegPath,
|
|
112
|
+
);
|
|
113
|
+
await validateImageFile(outputPath);
|
|
114
|
+
return { path: outputPath, timestamp, index };
|
|
115
|
+
} catch (fastSeekError) {
|
|
116
|
+
await deleteFileQuietly(outputPath);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await extractFrame(
|
|
120
|
+
videoPath,
|
|
121
|
+
timestamp,
|
|
122
|
+
outputPath,
|
|
123
|
+
imageFormat,
|
|
124
|
+
jpegQuality,
|
|
125
|
+
false,
|
|
126
|
+
debug,
|
|
127
|
+
ffmpegPath,
|
|
128
|
+
);
|
|
129
|
+
await validateImageFile(outputPath);
|
|
130
|
+
return { path: outputPath, timestamp, index };
|
|
131
|
+
} catch (accurateSeekError) {
|
|
132
|
+
await deleteFileQuietly(outputPath);
|
|
133
|
+
warnings.push(
|
|
134
|
+
`Could not extract candidate frame at ${timestamp.toFixed(3)}s: ${accurateSeekError.message || fastSeekError.message}`,
|
|
135
|
+
);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const extractFrame = async (
|
|
142
|
+
videoPath,
|
|
143
|
+
timestamp,
|
|
144
|
+
outputPath,
|
|
145
|
+
imageFormat,
|
|
146
|
+
jpegQuality,
|
|
147
|
+
fastSeek,
|
|
148
|
+
debug,
|
|
149
|
+
ffmpegPath,
|
|
150
|
+
) => {
|
|
151
|
+
const qualityArgs = imageFormat === 'jpg' ? ['-q:v', String(jpegQuality)] : [];
|
|
152
|
+
const outputArgs = ['-frames:v', '1', '-an', ...qualityArgs, outputPath];
|
|
153
|
+
const timestampText = formatTimestamp(timestamp);
|
|
154
|
+
const args = fastSeek
|
|
155
|
+
? ['-hide_banner', '-y', '-ss', timestampText, '-i', videoPath, ...outputArgs]
|
|
156
|
+
: ['-hide_banner', '-y', '-i', videoPath, '-ss', timestampText, ...outputArgs];
|
|
157
|
+
|
|
158
|
+
const binaries = resolveBinaryPaths({ ffmpegPath });
|
|
159
|
+
const { stderr } = await runCommand(binaries.ffmpegPath, args);
|
|
160
|
+
if (debug && stderr.trim()) console.log('[ffmpeg]', stderr.trim());
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const extractSequentialCandidateFrames = async ({
|
|
164
|
+
videoPath,
|
|
165
|
+
timestamps,
|
|
166
|
+
outputDirectory,
|
|
167
|
+
imageFormat,
|
|
168
|
+
jpegQuality,
|
|
169
|
+
debug,
|
|
170
|
+
warnings,
|
|
171
|
+
createdFramePaths,
|
|
172
|
+
ffmpegPath,
|
|
173
|
+
naming,
|
|
174
|
+
}) => {
|
|
175
|
+
const tempPattern = path.join(outputDirectory, `candidate-${uuidv4()}-%04d.${imageFormat}`);
|
|
176
|
+
const fps = Math.max(1, Math.min(12, timestamps.length));
|
|
177
|
+
const qualityArgs = imageFormat === 'jpg' ? ['-q:v', String(jpegQuality)] : [];
|
|
178
|
+
const args = [
|
|
179
|
+
'-hide_banner',
|
|
180
|
+
'-y',
|
|
181
|
+
'-i',
|
|
182
|
+
videoPath,
|
|
183
|
+
'-map',
|
|
184
|
+
'0:v:0',
|
|
185
|
+
'-vf',
|
|
186
|
+
`fps=${fps}`,
|
|
187
|
+
'-frames:v',
|
|
188
|
+
String(timestamps.length),
|
|
189
|
+
...qualityArgs,
|
|
190
|
+
tempPattern,
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const binaries = resolveBinaryPaths({ ffmpegPath });
|
|
194
|
+
const { stderr } = await runCommand(binaries.ffmpegPath, args);
|
|
195
|
+
if (debug && stderr.trim()) console.log('[ffmpeg sequential]', stderr.trim());
|
|
196
|
+
|
|
197
|
+
const extractedPaths = await findSequentialOutputs(tempPattern);
|
|
198
|
+
const candidates = [];
|
|
199
|
+
|
|
200
|
+
for (let index = 0; index < extractedPaths.length; index += 1) {
|
|
201
|
+
const originalPath = extractedPaths[index];
|
|
202
|
+
const timestamp = timestamps[Math.min(index, timestamps.length - 1)];
|
|
203
|
+
const outputPath = await getAvailableCandidatePath(
|
|
204
|
+
outputDirectory,
|
|
205
|
+
index,
|
|
206
|
+
timestamp,
|
|
207
|
+
imageFormat,
|
|
208
|
+
naming,
|
|
209
|
+
);
|
|
210
|
+
createdFramePaths.add(outputPath);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await fs.promises.rename(originalPath, outputPath);
|
|
214
|
+
await validateImageFile(outputPath);
|
|
215
|
+
candidates.push({
|
|
216
|
+
path: outputPath,
|
|
217
|
+
timestamp,
|
|
218
|
+
index,
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await deleteFileQuietly(originalPath);
|
|
222
|
+
await deleteFileQuietly(outputPath);
|
|
223
|
+
warnings.push(`Could not validate sequential fallback frame ${originalPath}: ${error.message}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (candidates.length > 0 && candidates.length < timestamps.length) {
|
|
228
|
+
warnings.push(
|
|
229
|
+
`Sequential FPS fallback extracted ${candidates.length} of ${timestamps.length} requested candidate frames.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return candidates;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const findSequentialOutputs = async (tempPattern) => {
|
|
237
|
+
const directory = path.dirname(tempPattern);
|
|
238
|
+
const basename = path.basename(tempPattern);
|
|
239
|
+
const [prefix, suffix] = basename.split('%04d');
|
|
240
|
+
const entries = await fs.promises.readdir(directory);
|
|
241
|
+
|
|
242
|
+
return entries
|
|
243
|
+
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(suffix))
|
|
244
|
+
.sort()
|
|
245
|
+
.map((entry) => path.join(directory, entry));
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const createCandidateNaming = (candidateCount, videoDurationMs) => ({
|
|
249
|
+
indexDigits: Math.max(3, String(Math.max(1, candidateCount)).length),
|
|
250
|
+
timestampDigits: Math.max(6, String(Math.max(0, Math.round(videoDurationMs || 0))).length),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const getAvailableCandidatePath = async (
|
|
254
|
+
outputDirectory,
|
|
255
|
+
index,
|
|
256
|
+
timestamp,
|
|
257
|
+
imageFormat,
|
|
258
|
+
naming,
|
|
259
|
+
) => {
|
|
260
|
+
const candidateNumber = String(index + 1).padStart(naming.indexDigits, '0');
|
|
261
|
+
const timestampMs = String(toTimestampMs(timestamp)).padStart(naming.timestampDigits, '0');
|
|
262
|
+
|
|
263
|
+
return getAvailableFilePath(
|
|
264
|
+
outputDirectory,
|
|
265
|
+
`candidate-${candidateNumber}-t${timestampMs}ms.${imageFormat}`,
|
|
266
|
+
);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const toTimestampMs = (timestampSeconds) => Math.max(0, Math.round(timestampSeconds * 1000));
|
|
270
|
+
|
|
271
|
+
export const validateImageFile = async (imagePath) => {
|
|
272
|
+
const stat = await fs.promises.stat(imagePath);
|
|
273
|
+
|
|
274
|
+
if (!stat.isFile() || stat.size < MIN_FRAME_BYTES) {
|
|
275
|
+
throw new Error(`Extracted frame is missing or too small: ${imagePath}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await sharp(imagePath).metadata();
|
|
279
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fs,
|
|
3
|
+
getAvailableFilePath,
|
|
4
|
+
path,
|
|
5
|
+
resolveOutputDirectory,
|
|
6
|
+
} from '../shared/files.js';
|
|
7
|
+
import { cleanupErroredFrames, cleanupRejectedCandidates } from './cleanup.js';
|
|
8
|
+
import { extractCandidateFrames } from './extraction.js';
|
|
9
|
+
import { rejectLowInformationFrames } from './lowInformation.js';
|
|
10
|
+
import {
|
|
11
|
+
resolveOptions,
|
|
12
|
+
resolveSamplingCounts,
|
|
13
|
+
resolveSamplingWindow,
|
|
14
|
+
} from './options.js';
|
|
15
|
+
import { probeVideo } from './probe.js';
|
|
16
|
+
import {
|
|
17
|
+
addFallbackFrames,
|
|
18
|
+
selectRepresentativeFrames,
|
|
19
|
+
toPublicFrame,
|
|
20
|
+
} from './selection.js';
|
|
21
|
+
import { generateCandidateTimestamps } from './timestamps.js';
|
|
22
|
+
|
|
23
|
+
export const sampleFrames = async (videoPath, options = {}) => {
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const resolved = resolveOptions(options, warnings);
|
|
26
|
+
const createdFramePaths = new Set();
|
|
27
|
+
const acceptedFramePaths = new Set();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const video = await probeVideo(videoPath, { ffprobePath: resolved.ffprobePath });
|
|
31
|
+
const window = resolveSamplingWindow(video.durationSeconds, resolved, warnings);
|
|
32
|
+
const sampling = resolveSamplingCounts(window.durationSeconds, resolved);
|
|
33
|
+
const timestamps = generateCandidateTimestamps(window, sampling.candidateCount);
|
|
34
|
+
const outputDirectory = await resolveOutputDirectory(resolved.outputDir || process.cwd());
|
|
35
|
+
const candidates = await extractCandidateFrames({
|
|
36
|
+
videoPath,
|
|
37
|
+
timestamps,
|
|
38
|
+
outputDirectory,
|
|
39
|
+
imageFormat: resolved.imageFormat,
|
|
40
|
+
jpegQuality: resolved.jpegQuality,
|
|
41
|
+
debug: resolved.debug,
|
|
42
|
+
warnings,
|
|
43
|
+
createdFramePaths,
|
|
44
|
+
ffmpegPath: resolved.ffmpegPath,
|
|
45
|
+
videoDurationMs: window.videoDurationMs,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (candidates.length === 0) {
|
|
49
|
+
throw new Error(`FFmpeg frame extraction failed completely for video: ${videoPath}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lowInformation = await rejectLowInformationFrames(candidates, warnings);
|
|
53
|
+
const selection = await selectRepresentativeFrames(
|
|
54
|
+
lowInformation.usableCandidates,
|
|
55
|
+
sampling,
|
|
56
|
+
resolved,
|
|
57
|
+
warnings,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
ensureSelectionSucceeded(selection, videoPath);
|
|
61
|
+
|
|
62
|
+
const fallback = await addFallbackIfNeeded({
|
|
63
|
+
accepted: selection.accepted,
|
|
64
|
+
candidates: lowInformation.usableCandidates,
|
|
65
|
+
sampling,
|
|
66
|
+
resolved,
|
|
67
|
+
warnings,
|
|
68
|
+
});
|
|
69
|
+
const accepted = fallback.accepted.sort((a, b) => a.timestamp - b.timestamp);
|
|
70
|
+
await renameAcceptedFrames(accepted, outputDirectory, resolved.imageFormat);
|
|
71
|
+
|
|
72
|
+
for (const frame of accepted) acceptedFramePaths.add(frame.path);
|
|
73
|
+
await cleanupRejectedCandidates(candidates, acceptedFramePaths, resolved.cleanupRejected);
|
|
74
|
+
|
|
75
|
+
return buildSamplerResult({
|
|
76
|
+
videoPath,
|
|
77
|
+
video,
|
|
78
|
+
outputDirectory,
|
|
79
|
+
sampling,
|
|
80
|
+
window,
|
|
81
|
+
resolved,
|
|
82
|
+
timestamps,
|
|
83
|
+
accepted,
|
|
84
|
+
selection,
|
|
85
|
+
lowInformation,
|
|
86
|
+
fallbackFramesAdded: fallback.added,
|
|
87
|
+
warnings,
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await cleanupErroredFrames(createdFramePaths, acceptedFramePaths);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const ensureSelectionSucceeded = (selection, videoPath) => {
|
|
96
|
+
if (selection.hashesAttempted > 0 && selection.hashesSucceeded === 0) {
|
|
97
|
+
throw new Error(`Image hash generation failed completely for extracted frames from: ${videoPath}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (selection.accepted.length === 0) {
|
|
101
|
+
throw new Error(`No usable representative frames could be selected from video: ${videoPath}`);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const addFallbackIfNeeded = async ({ accepted, candidates, sampling, resolved, warnings }) => {
|
|
106
|
+
if (!resolved.includeFallbackFrames || accepted.length >= sampling.resolvedMinFrames) {
|
|
107
|
+
return { accepted, added: 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return addFallbackFrames({
|
|
111
|
+
accepted,
|
|
112
|
+
candidates,
|
|
113
|
+
sampling,
|
|
114
|
+
resolved,
|
|
115
|
+
warnings,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const buildSamplerResult = ({
|
|
120
|
+
videoPath,
|
|
121
|
+
video,
|
|
122
|
+
outputDirectory,
|
|
123
|
+
sampling,
|
|
124
|
+
window,
|
|
125
|
+
resolved,
|
|
126
|
+
timestamps,
|
|
127
|
+
accepted,
|
|
128
|
+
selection,
|
|
129
|
+
lowInformation,
|
|
130
|
+
fallbackFramesAdded,
|
|
131
|
+
warnings,
|
|
132
|
+
}) => ({
|
|
133
|
+
video: {
|
|
134
|
+
path: videoPath,
|
|
135
|
+
durationSeconds: video.durationSeconds,
|
|
136
|
+
width: video.width,
|
|
137
|
+
height: video.height,
|
|
138
|
+
fps: video.fps,
|
|
139
|
+
},
|
|
140
|
+
output: {
|
|
141
|
+
directory: outputDirectory,
|
|
142
|
+
},
|
|
143
|
+
options: {
|
|
144
|
+
resolvedTargetFrames: sampling.resolvedTargetFrames,
|
|
145
|
+
maxFrames: resolved.maxFrames,
|
|
146
|
+
minFrames: sampling.resolvedMinFrames,
|
|
147
|
+
minGapMs: resolved.minGapMs,
|
|
148
|
+
startTimeMs: Math.round(window.startTimeMs),
|
|
149
|
+
endBeforeMs: Math.round(window.endBeforeMs),
|
|
150
|
+
candidateCount: timestamps.length,
|
|
151
|
+
similarityThreshold: resolved.similarityThreshold,
|
|
152
|
+
},
|
|
153
|
+
frames: accepted.map(toPublicFrame),
|
|
154
|
+
stats: {
|
|
155
|
+
candidatesExtracted: lowInformation.usableCandidates.length + lowInformation.rejectedLowInformation,
|
|
156
|
+
candidatesRejectedAsSimilar: selection.candidatesRejectedAsSimilar,
|
|
157
|
+
candidatesRejectedForSpacing: selection.candidatesRejectedForSpacing,
|
|
158
|
+
candidatesRejectedAsLowInformation: lowInformation.rejectedLowInformation,
|
|
159
|
+
fallbackFramesAdded,
|
|
160
|
+
returnedFrames: accepted.length,
|
|
161
|
+
},
|
|
162
|
+
warnings,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const renameAcceptedFrames = async (frames, outputDirectory, imageFormat) => {
|
|
166
|
+
const frameDigits = Math.max(3, String(frames.length).length);
|
|
167
|
+
const timestampDigits = Math.max(
|
|
168
|
+
6,
|
|
169
|
+
...frames.map((frame) => String(toTimestampMs(frame.timestamp)).length),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
173
|
+
const frame = frames[index];
|
|
174
|
+
const frameNumber = String(index + 1).padStart(frameDigits, '0');
|
|
175
|
+
const timestampMs = String(toTimestampMs(frame.timestamp)).padStart(timestampDigits, '0');
|
|
176
|
+
const destination = await getAvailableFilePath(
|
|
177
|
+
outputDirectory,
|
|
178
|
+
`frame-${frameNumber}-t${timestampMs}ms.${imageFormat}`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (path.resolve(frame.path) === destination) continue;
|
|
182
|
+
|
|
183
|
+
await fs.promises.rename(frame.path, destination);
|
|
184
|
+
frame.path = destination;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const toTimestampMs = (timestampSeconds) => Math.max(0, Math.round(timestampSeconds * 1000));
|
|
189
|
+
|
|
190
|
+
export const sampleRepresentativeFrames = sampleFrames;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const sharp = require('sharp');
|
|
5
|
+
const phash = require('sharp-phash');
|
|
6
|
+
|
|
7
|
+
sharp.cache(false);
|
|
8
|
+
|
|
9
|
+
export const getImageHash = async (imagePath) => {
|
|
10
|
+
const hash = await phash(imagePath);
|
|
11
|
+
|
|
12
|
+
if (typeof hash === 'string') return hash;
|
|
13
|
+
if (typeof hash === 'bigint') return hash.toString(2).padStart(64, '0');
|
|
14
|
+
|
|
15
|
+
throw new Error(`Unsupported perceptual hash return type: ${typeof hash}`);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const hammingDistance = (hashA, hashB) => {
|
|
19
|
+
if (typeof hashA !== 'string' || typeof hashB !== 'string') {
|
|
20
|
+
throw new Error('Cannot compare non-string perceptual hashes.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const binaryA = normalizeHashToBinary(hashA);
|
|
24
|
+
const binaryB = normalizeHashToBinary(hashB);
|
|
25
|
+
const length = Math.min(binaryA.length, binaryB.length);
|
|
26
|
+
let distance = Math.abs(binaryA.length - binaryB.length);
|
|
27
|
+
|
|
28
|
+
for (let index = 0; index < length; index += 1) {
|
|
29
|
+
if (binaryA[index] !== binaryB[index]) distance += 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return distance;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const normalizeHashToBinary = (hash) => {
|
|
36
|
+
if (/^[01]+$/.test(hash)) return hash;
|
|
37
|
+
|
|
38
|
+
if (/^[a-f0-9]+$/i.test(hash)) {
|
|
39
|
+
return hash
|
|
40
|
+
.split('')
|
|
41
|
+
.map((char) => parseInt(char, 16).toString(2).padStart(4, '0'))
|
|
42
|
+
.join('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return hash;
|
|
46
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { average } from '../shared/numbers.js';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const sharp = require('sharp');
|
|
6
|
+
|
|
7
|
+
sharp.cache(false);
|
|
8
|
+
|
|
9
|
+
export const rejectLowInformationFrames = async (candidates, warnings) => {
|
|
10
|
+
const usableCandidates = [];
|
|
11
|
+
let rejectedLowInformation = 0;
|
|
12
|
+
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
try {
|
|
15
|
+
const lowInfo = await getLowInformationReason(candidate.path);
|
|
16
|
+
|
|
17
|
+
if (lowInfo) {
|
|
18
|
+
candidate.lowInformationReason = lowInfo;
|
|
19
|
+
rejectedLowInformation += 1;
|
|
20
|
+
} else {
|
|
21
|
+
usableCandidates.push(candidate);
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
candidate.lowInformationReason = 'stats_failed';
|
|
25
|
+
rejectedLowInformation += 1;
|
|
26
|
+
warnings.push(`Could not inspect candidate frame ${candidate.path}: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (rejectedLowInformation > 0 && rejectedLowInformation >= Math.ceil(candidates.length / 2)) {
|
|
31
|
+
warnings.push(`${rejectedLowInformation} candidate frames were rejected as low-information.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (usableCandidates.length === 0) {
|
|
35
|
+
throw new Error('No usable frames could be extracted; all candidates were low-information or unreadable.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { usableCandidates, rejectedLowInformation };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getLowInformationReason = async (imagePath) => {
|
|
42
|
+
const stats = await sharp(imagePath).stats();
|
|
43
|
+
const channels = stats.channels.slice(0, 3);
|
|
44
|
+
const mean = average(channels.map((channel) => channel.mean));
|
|
45
|
+
const stdev = average(channels.map((channel) => channel.stdev));
|
|
46
|
+
|
|
47
|
+
if (mean <= 8 && stdev <= 12) return 'mostly_black';
|
|
48
|
+
if (mean >= 247 && stdev <= 12) return 'mostly_white';
|
|
49
|
+
if (stdev <= 2.5) return 'extremely_low_variance';
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
};
|