@larkup/tool-video-audio 0.2.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 +176 -0
- package/README.md +55 -0
- package/dist/audio-processor.d.ts +65 -0
- package/dist/audio-processor.js +507 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/url-importer.d.ts +40 -0
- package/dist/url-importer.js +291 -0
- package/dist/video-processor.d.ts +127 -0
- package/dist/video-processor.js +469 -0
- package/package.json +51 -0
- package/src/audio-processor.ts +666 -0
- package/src/index.ts +44 -0
- package/src/nodejs-whisper.d.ts +25 -0
- package/src/url-importer.ts +371 -0
- package/src/video-processor.ts +687 -0
- package/tool.manifest.json +63 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Fuse speech and visual observations onto one timeline. Each searchable unit
|
|
5
|
+
* contains everything that happened in the same time window instead of an
|
|
6
|
+
* unrelated transcript or frame caption.
|
|
7
|
+
*/
|
|
8
|
+
export function buildMultimodalSegments(transcript, visuals, durationSecs, targetWindowSecs = 60) {
|
|
9
|
+
const evidence = [...transcript, ...visuals];
|
|
10
|
+
if (evidence.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
const knownEnd = Math.max(durationSecs, ...evidence.map((item) => item.endSecs));
|
|
13
|
+
const segments = [];
|
|
14
|
+
for (let startSecs = 0; startSecs < knownEnd; startSecs += targetWindowSecs) {
|
|
15
|
+
const endSecs = Math.min(startSecs + targetWindowSecs, knownEnd);
|
|
16
|
+
const overlaps = (item) => item.startSecs < endSecs && item.endSecs > startSecs;
|
|
17
|
+
const spoken = transcript
|
|
18
|
+
.filter(overlaps)
|
|
19
|
+
.map((item) => item.text.trim())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
const seen = visuals
|
|
22
|
+
.filter(overlaps)
|
|
23
|
+
.map((item) => item.text.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
if (spoken.length === 0 && seen.length === 0)
|
|
26
|
+
continue;
|
|
27
|
+
const transcriptText = [...new Set(spoken)].join(' ');
|
|
28
|
+
const visualContext = [...new Set(seen)].join(' ');
|
|
29
|
+
const parts = [
|
|
30
|
+
`Timeline: ${formatTimestamp(startSecs)}–${formatTimestamp(endSecs)}.`,
|
|
31
|
+
transcriptText ? `Speech: ${transcriptText}` : '',
|
|
32
|
+
visualContext ? `Visual sequence, actions, and on-screen text: ${visualContext}` : '',
|
|
33
|
+
].filter(Boolean);
|
|
34
|
+
segments.push({
|
|
35
|
+
text: parts.join('\n'),
|
|
36
|
+
transcript: transcriptText,
|
|
37
|
+
visualContext,
|
|
38
|
+
startSecs,
|
|
39
|
+
endSecs,
|
|
40
|
+
sequence: Math.floor(startSecs / targetWindowSecs),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return segments;
|
|
44
|
+
}
|
|
45
|
+
function formatTimestamp(seconds) {
|
|
46
|
+
const hours = Math.floor(seconds / 3600);
|
|
47
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
48
|
+
const secs = Math.floor(seconds % 60);
|
|
49
|
+
return hours > 0
|
|
50
|
+
? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
|
51
|
+
: `${minutes}:${String(secs).padStart(2, '0')}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a bounded frame-sampling plan without treating maxFrames as a quota.
|
|
55
|
+
*
|
|
56
|
+
* Edited videos receive frequent anchors and scene-change coverage. As duration
|
|
57
|
+
* grows, the anchor cadence widens so multi-hour camera and screen recordings
|
|
58
|
+
* remain searchable without generating hundreds of near-identical frames.
|
|
59
|
+
*/
|
|
60
|
+
export function createVideoSamplingPlan(durationSecs, configuredInterval = 30, maxFrames = 100) {
|
|
61
|
+
const duration = Number.isFinite(durationSecs) ? Math.max(0, durationSecs) : 0;
|
|
62
|
+
const hardLimit = Number.isFinite(maxFrames) ? Math.max(1, Math.floor(maxFrames)) : 100;
|
|
63
|
+
const requestedInterval = Number.isFinite(configuredInterval) && configuredInterval > 0 ? configuredInterval : 30;
|
|
64
|
+
let periodicFloorSecs;
|
|
65
|
+
let sceneCadenceSecs;
|
|
66
|
+
if (duration <= 15 * 60) {
|
|
67
|
+
periodicFloorSecs = 15;
|
|
68
|
+
sceneCadenceSecs = 15;
|
|
69
|
+
}
|
|
70
|
+
else if (duration <= 60 * 60) {
|
|
71
|
+
periodicFloorSecs = 30;
|
|
72
|
+
sceneCadenceSecs = 30;
|
|
73
|
+
}
|
|
74
|
+
else if (duration <= 4 * 60 * 60) {
|
|
75
|
+
periodicFloorSecs = 60;
|
|
76
|
+
sceneCadenceSecs = 60;
|
|
77
|
+
}
|
|
78
|
+
else if (duration <= 12 * 60 * 60) {
|
|
79
|
+
periodicFloorSecs = 10 * 60;
|
|
80
|
+
sceneCadenceSecs = 10 * 60;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
periodicFloorSecs = 5 * 60;
|
|
84
|
+
sceneCadenceSecs = 5 * 60;
|
|
85
|
+
}
|
|
86
|
+
const preferredInterval = Math.max(requestedInterval, periodicFloorSecs);
|
|
87
|
+
const desiredPeriodicCount = Math.max(1, Math.ceil(duration / preferredInterval));
|
|
88
|
+
const desiredSceneCount = duration > 0 ? Math.max(1, Math.ceil(duration / sceneCadenceSecs)) : 0;
|
|
89
|
+
const desiredTotal = desiredPeriodicCount + desiredSceneCount;
|
|
90
|
+
const desiredEndingCount = duration >= 60 ? Math.ceil(Math.min(90, duration) / (duration <= 2 * 60 * 60 ? 5 : 15)) : 0;
|
|
91
|
+
const endingFrameCount = desiredEndingCount > 0
|
|
92
|
+
? Math.min(desiredEndingCount, hardLimit === 1 ? 1 : Math.max(1, Math.min(hardLimit - 1, Math.floor(hardLimit * 0.2))))
|
|
93
|
+
: 0;
|
|
94
|
+
const coverageLimit = Math.max(0, hardLimit - endingFrameCount);
|
|
95
|
+
let periodicFrameCount;
|
|
96
|
+
let sceneFrameCount;
|
|
97
|
+
if (coverageLimit === 0) {
|
|
98
|
+
periodicFrameCount = 0;
|
|
99
|
+
sceneFrameCount = 0;
|
|
100
|
+
}
|
|
101
|
+
else if (desiredTotal <= coverageLimit) {
|
|
102
|
+
periodicFrameCount = desiredPeriodicCount;
|
|
103
|
+
sceneFrameCount = desiredSceneCount;
|
|
104
|
+
}
|
|
105
|
+
else if (coverageLimit === 1 || desiredSceneCount === 0) {
|
|
106
|
+
periodicFrameCount = 1;
|
|
107
|
+
sceneFrameCount = 0;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// Preserve both uniform and change-based evidence when the natural plan
|
|
111
|
+
// exceeds the hard limit, in proportion to the useful frames each needs.
|
|
112
|
+
periodicFrameCount = Math.max(1, Math.min(coverageLimit - 1, Math.round((coverageLimit * desiredPeriodicCount) / desiredTotal)));
|
|
113
|
+
sceneFrameCount = coverageLimit - periodicFrameCount;
|
|
114
|
+
}
|
|
115
|
+
const periodicIntervalSecs = periodicFrameCount > 0 && periodicFrameCount < desiredPeriodicCount && duration > 0
|
|
116
|
+
? Math.max(preferredInterval, duration / periodicFrameCount)
|
|
117
|
+
: preferredInterval;
|
|
118
|
+
const minimumSceneGapSecs = sceneFrameCount > 0 ? Math.max(1, periodicIntervalSecs / 2, duration / sceneFrameCount) : 0;
|
|
119
|
+
return {
|
|
120
|
+
durationSecs: duration,
|
|
121
|
+
maxFrames: hardLimit,
|
|
122
|
+
periodicIntervalSecs,
|
|
123
|
+
periodicFrameCount,
|
|
124
|
+
sceneFrameCount,
|
|
125
|
+
endingFrameCount,
|
|
126
|
+
minimumSceneGapSecs,
|
|
127
|
+
estimatedFrameCount: periodicFrameCount + sceneFrameCount + endingFrameCount,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Densely sample the ending where final results and decisions commonly appear. */
|
|
131
|
+
export function createEndingSamplingPlan(durationSecs, maxAdditionalFrames) {
|
|
132
|
+
const duration = Number.isFinite(durationSecs) ? Math.max(0, durationSecs) : 0;
|
|
133
|
+
const capacity = Number.isFinite(maxAdditionalFrames)
|
|
134
|
+
? Math.max(0, Math.floor(maxAdditionalFrames))
|
|
135
|
+
: 0;
|
|
136
|
+
if (duration < 60 || capacity === 0) {
|
|
137
|
+
return {
|
|
138
|
+
startSecs: Math.max(0, duration - 90),
|
|
139
|
+
intervalSecs: 5,
|
|
140
|
+
frameCount: 0,
|
|
141
|
+
timestamps: [],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const windowSecs = Math.min(90, duration);
|
|
145
|
+
const preferredIntervalSecs = duration <= 2 * 60 * 60 ? 5 : 15;
|
|
146
|
+
const desiredFrameCount = Math.ceil(windowSecs / preferredIntervalSecs);
|
|
147
|
+
const frameCount = Math.min(capacity, desiredFrameCount);
|
|
148
|
+
const windowStart = Math.max(0, duration - windowSecs);
|
|
149
|
+
const endSecs = Math.max(windowStart, duration - Math.min(1, preferredIntervalSecs));
|
|
150
|
+
const hasFullCadence = frameCount === desiredFrameCount;
|
|
151
|
+
const startSecs = frameCount === 1 ? Math.max(windowStart, duration - preferredIntervalSecs) : windowStart;
|
|
152
|
+
const intervalSecs = hasFullCadence
|
|
153
|
+
? preferredIntervalSecs
|
|
154
|
+
: frameCount > 1
|
|
155
|
+
? Math.max(1, (endSecs - startSecs) / (frameCount - 1))
|
|
156
|
+
: preferredIntervalSecs;
|
|
157
|
+
return {
|
|
158
|
+
startSecs,
|
|
159
|
+
intervalSecs,
|
|
160
|
+
frameCount,
|
|
161
|
+
timestamps: Array.from({ length: frameCount }, (_, index) => startSecs + index * intervalSecs),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Process a video file: extract audio and keyframes.
|
|
166
|
+
*/
|
|
167
|
+
export async function processVideo(videoPath, options) {
|
|
168
|
+
const ffmpeg = await importFfmpeg();
|
|
169
|
+
await fs.mkdir(options.outputDir, { recursive: true });
|
|
170
|
+
const framesDir = path.join(options.outputDir, 'frames');
|
|
171
|
+
await fs.mkdir(framesDir, { recursive: true });
|
|
172
|
+
// 1. Probe video metadata
|
|
173
|
+
const meta = await probeVideo(ffmpeg, videoPath);
|
|
174
|
+
const audioPath = path.join(options.outputDir, 'audio.wav');
|
|
175
|
+
const interval = options.frameIntervalSecs ?? 10;
|
|
176
|
+
const maxFrames = options.maxFrames ?? 100;
|
|
177
|
+
let audioProgress = options.skipAudioExtraction ? 1 : 0;
|
|
178
|
+
let frameProgress = 0;
|
|
179
|
+
const reportExtractionProgress = () => {
|
|
180
|
+
options.onProgress?.(options.skipAudioExtraction ? frameProgress : (audioProgress + frameProgress) / 2);
|
|
181
|
+
};
|
|
182
|
+
// Cameras and screen recordings do not always have an audio stream. Visual
|
|
183
|
+
// indexing must still succeed for those files.
|
|
184
|
+
const extractAudioPromise = options.skipAudioExtraction
|
|
185
|
+
? Promise.resolve(undefined)
|
|
186
|
+
: extractAudio(ffmpeg, videoPath, audioPath, options.threads, (progress) => {
|
|
187
|
+
audioProgress = Math.max(audioProgress, progress);
|
|
188
|
+
reportExtractionProgress();
|
|
189
|
+
})
|
|
190
|
+
.then(() => {
|
|
191
|
+
audioProgress = 1;
|
|
192
|
+
reportExtractionProgress();
|
|
193
|
+
return audioPath;
|
|
194
|
+
})
|
|
195
|
+
.catch(() => {
|
|
196
|
+
// Videos without an audio stream remain valid visual-indexing input.
|
|
197
|
+
audioProgress = 1;
|
|
198
|
+
reportExtractionProgress();
|
|
199
|
+
return undefined;
|
|
200
|
+
});
|
|
201
|
+
const extractFramesPromise = extractSceneFrames(videoPath, {
|
|
202
|
+
outputDir: framesDir,
|
|
203
|
+
intervalSecs: interval,
|
|
204
|
+
maxFrames,
|
|
205
|
+
durationSecs: meta.durationSecs,
|
|
206
|
+
sceneThreshold: options.sceneThreshold,
|
|
207
|
+
threads: options.threads,
|
|
208
|
+
onProgress: (progress) => {
|
|
209
|
+
frameProgress = Math.max(frameProgress, progress);
|
|
210
|
+
reportExtractionProgress();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
let frames;
|
|
214
|
+
let extractedAudioPath;
|
|
215
|
+
if (options.parallelExtraction) {
|
|
216
|
+
[extractedAudioPath, frames] = await Promise.all([extractAudioPromise, extractFramesPromise]);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
extractedAudioPath = await extractAudioPromise;
|
|
220
|
+
frames = await extractFramesPromise;
|
|
221
|
+
}
|
|
222
|
+
return { audioPath: extractedAudioPath, frames, meta };
|
|
223
|
+
}
|
|
224
|
+
/** Extract scene-change frames in one ffmpeg process, with adaptive interval fallback. */
|
|
225
|
+
export async function extractSceneFrames(videoPath, options) {
|
|
226
|
+
const ffmpeg = await importFfmpeg();
|
|
227
|
+
await fs.mkdir(options.outputDir, { recursive: true });
|
|
228
|
+
const duration = options.durationSecs || (await probeVideo(ffmpeg, videoPath)).durationSecs;
|
|
229
|
+
const plan = createVideoSamplingPlan(duration, options.intervalSecs, options.maxFrames);
|
|
230
|
+
const totalPlannedWork = Math.max(1, plan.estimatedFrameCount);
|
|
231
|
+
const periodicWeight = plan.periodicFrameCount / totalPlannedWork;
|
|
232
|
+
const sceneWeight = plan.sceneFrameCount / totalPlannedWork;
|
|
233
|
+
const endingWeight = plan.endingFrameCount / totalPlannedWork;
|
|
234
|
+
let reportedProgress = 0;
|
|
235
|
+
const reportProgress = (progress) => {
|
|
236
|
+
reportedProgress = Math.max(reportedProgress, Math.max(0, Math.min(1, progress)));
|
|
237
|
+
options.onProgress?.(reportedProgress);
|
|
238
|
+
};
|
|
239
|
+
const addEndingEvidence = async (frames) => {
|
|
240
|
+
const remaining = Math.max(0, plan.maxFrames - frames.length);
|
|
241
|
+
const endingPlan = createEndingSamplingPlan(duration, Math.min(remaining, plan.endingFrameCount));
|
|
242
|
+
if (endingPlan.frameCount === 0) {
|
|
243
|
+
reportProgress(1);
|
|
244
|
+
return frames.slice(0, plan.maxFrames);
|
|
245
|
+
}
|
|
246
|
+
// Outcomes, decisions, and final scoreboard animations often change several
|
|
247
|
+
// times within a few seconds. A short ending burst complements the sparse
|
|
248
|
+
// whole-video plan without turning the frame ceiling into a quota.
|
|
249
|
+
const endingFrames = await extractFrames(videoPath, {
|
|
250
|
+
outputDir: path.join(options.outputDir, 'ending'),
|
|
251
|
+
intervalSecs: endingPlan.intervalSecs,
|
|
252
|
+
maxFrames: endingPlan.frameCount,
|
|
253
|
+
durationSecs: duration,
|
|
254
|
+
startSecs: endingPlan.startSecs,
|
|
255
|
+
threads: options.threads,
|
|
256
|
+
onProgress: (progress) => reportProgress(periodicWeight + sceneWeight + progress * endingWeight),
|
|
257
|
+
});
|
|
258
|
+
const merged = [...frames, ...endingFrames].sort((left, right) => left.timestampSecs - right.timestampSecs);
|
|
259
|
+
const result = merged
|
|
260
|
+
.filter((frame, index) => index === 0 || Math.abs(frame.timestampSecs - merged[index - 1].timestampSecs) >= 1)
|
|
261
|
+
.slice(0, plan.maxFrames);
|
|
262
|
+
reportProgress(1);
|
|
263
|
+
return result;
|
|
264
|
+
};
|
|
265
|
+
// Uniform frames guarantee beginning-to-end coverage, while the independent
|
|
266
|
+
// scene pass captures meaningful changes between those anchors.
|
|
267
|
+
const periodicFrames = plan.periodicFrameCount === 0
|
|
268
|
+
? []
|
|
269
|
+
: duration >= 2 * 60 * 60
|
|
270
|
+
? await extractFramesAtTimestamps(ffmpeg, videoPath, Array.from({ length: plan.periodicFrameCount }, (_, index) => Math.min(Math.max(0, duration - 1), index * plan.periodicIntervalSecs)), {
|
|
271
|
+
outputDir: options.outputDir,
|
|
272
|
+
threads: options.threads,
|
|
273
|
+
onProgress: (progress) => reportProgress(progress * periodicWeight),
|
|
274
|
+
})
|
|
275
|
+
: await extractFrames(videoPath, {
|
|
276
|
+
outputDir: options.outputDir,
|
|
277
|
+
intervalSecs: plan.periodicIntervalSecs,
|
|
278
|
+
maxFrames: plan.periodicFrameCount,
|
|
279
|
+
durationSecs: duration,
|
|
280
|
+
threads: options.threads,
|
|
281
|
+
onProgress: (progress) => reportProgress(progress * periodicWeight),
|
|
282
|
+
});
|
|
283
|
+
if (plan.sceneFrameCount === 0)
|
|
284
|
+
return addEndingEvidence(periodicFrames);
|
|
285
|
+
const threshold = options.sceneThreshold ?? 0.3;
|
|
286
|
+
const sceneDir = path.join(options.outputDir, 'scenes');
|
|
287
|
+
await fs.mkdir(sceneDir, { recursive: true });
|
|
288
|
+
const pattern = path.join(sceneDir, 'scene_%04d.jpg');
|
|
289
|
+
let timestamps = [];
|
|
290
|
+
await new Promise((resolve) => {
|
|
291
|
+
const command = ffmpeg.default(videoPath);
|
|
292
|
+
const isLongRecording = duration >= 2 * 60 * 60;
|
|
293
|
+
if (isLongRecording) {
|
|
294
|
+
// Long surveillance/screen recordings are commonly mostly static. Decode
|
|
295
|
+
// keyframes only and score changes at a smaller resolution so an 8-hour
|
|
296
|
+
// input does not perform full-resolution scene math on every source frame.
|
|
297
|
+
command.inputOptions(['-skip_frame nokey']);
|
|
298
|
+
}
|
|
299
|
+
command
|
|
300
|
+
.videoFilters(isLongRecording
|
|
301
|
+
? `scale='min(640,iw)':-2,select='gt(scene,${threshold})*if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${plan.minimumSceneGapSecs}))',showinfo`
|
|
302
|
+
: `select='gt(scene,${threshold})*if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${plan.minimumSceneGapSecs}))',showinfo,scale='min(1280,iw)':-2`)
|
|
303
|
+
.outputOptions([
|
|
304
|
+
'-vsync vfr',
|
|
305
|
+
`-frames:v ${plan.sceneFrameCount}`,
|
|
306
|
+
...(options.threads ? [`-threads ${options.threads}`] : []),
|
|
307
|
+
])
|
|
308
|
+
.output(pattern)
|
|
309
|
+
.on('stderr', (line) => {
|
|
310
|
+
const match = line.match(/pts_time:([0-9.]+)/);
|
|
311
|
+
if (match)
|
|
312
|
+
timestamps.push(Number(match[1]));
|
|
313
|
+
})
|
|
314
|
+
.on('progress', (progress) => {
|
|
315
|
+
if (typeof progress.percent === 'number') {
|
|
316
|
+
reportProgress(periodicWeight + Math.min(1, progress.percent / 100) * sceneWeight);
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.on('end', () => resolve())
|
|
320
|
+
.on('error', () => {
|
|
321
|
+
// Scene detection is supplementary. Some ffmpeg versions report an
|
|
322
|
+
// encoder error when the selector legitimately emits zero frames.
|
|
323
|
+
// Uniform coverage frames must still make the video indexable.
|
|
324
|
+
resolve();
|
|
325
|
+
})
|
|
326
|
+
.run();
|
|
327
|
+
});
|
|
328
|
+
reportProgress(periodicWeight + sceneWeight);
|
|
329
|
+
const files = (await fs.readdir(sceneDir))
|
|
330
|
+
.filter((name) => /^scene_\d+\.jpg$/.test(name))
|
|
331
|
+
.sort()
|
|
332
|
+
.slice(0, plan.sceneFrameCount);
|
|
333
|
+
if (!files.length)
|
|
334
|
+
return addEndingEvidence(periodicFrames);
|
|
335
|
+
timestamps = timestamps.slice(-files.length);
|
|
336
|
+
const sceneFrames = files.map((name, index) => ({
|
|
337
|
+
path: path.join(sceneDir, name),
|
|
338
|
+
timestampSecs: timestamps[index] ?? (index * duration) / files.length,
|
|
339
|
+
}));
|
|
340
|
+
const merged = [...periodicFrames, ...sceneFrames].sort((left, right) => left.timestampSecs - right.timestampSecs);
|
|
341
|
+
const deduplicated = merged.filter((frame, index) => index === 0 || Math.abs(frame.timestampSecs - merged[index - 1].timestampSecs) >= 1);
|
|
342
|
+
return addEndingEvidence(deduplicated);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Use input seeking for sparse anchors in multi-hour media. This avoids decoding
|
|
346
|
+
* the entire recording once merely to retain a few dozen periodic frames.
|
|
347
|
+
*/
|
|
348
|
+
async function extractFramesAtTimestamps(ffmpeg, videoPath, timestamps, options) {
|
|
349
|
+
await fs.mkdir(options.outputDir, { recursive: true });
|
|
350
|
+
const results = new Array(timestamps.length);
|
|
351
|
+
let cursor = 0;
|
|
352
|
+
let completed = 0;
|
|
353
|
+
await Promise.all(Array.from({ length: Math.min(2, timestamps.length) }, async () => {
|
|
354
|
+
while (cursor < timestamps.length) {
|
|
355
|
+
const index = cursor++;
|
|
356
|
+
const timestampSecs = timestamps[index];
|
|
357
|
+
const outputPath = path.join(options.outputDir, `frame_${String(index + 1).padStart(4, '0')}.jpg`);
|
|
358
|
+
await new Promise((resolve, reject) => {
|
|
359
|
+
ffmpeg
|
|
360
|
+
.default(videoPath)
|
|
361
|
+
.seekInput(timestampSecs)
|
|
362
|
+
.videoFilters(`scale='min(1280,iw)':-2`)
|
|
363
|
+
.outputOptions([
|
|
364
|
+
'-frames:v 1',
|
|
365
|
+
...(options.threads ? [`-threads ${options.threads}`] : []),
|
|
366
|
+
])
|
|
367
|
+
.output(outputPath)
|
|
368
|
+
.on('end', () => resolve())
|
|
369
|
+
.on('error', (error) => reject(error))
|
|
370
|
+
.run();
|
|
371
|
+
});
|
|
372
|
+
results[index] = { path: outputPath, timestampSecs };
|
|
373
|
+
completed += 1;
|
|
374
|
+
options.onProgress?.(completed / timestamps.length);
|
|
375
|
+
}
|
|
376
|
+
}));
|
|
377
|
+
return results;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Extract keyframes from a video at regular intervals.
|
|
381
|
+
*/
|
|
382
|
+
export async function extractFrames(videoPath, options) {
|
|
383
|
+
const ffmpeg = await importFfmpeg();
|
|
384
|
+
await fs.mkdir(options.outputDir, { recursive: true });
|
|
385
|
+
// Get duration if not provided
|
|
386
|
+
let duration = options.durationSecs;
|
|
387
|
+
if (!duration) {
|
|
388
|
+
const meta = await probeVideo(ffmpeg, videoPath);
|
|
389
|
+
duration = meta.durationSecs;
|
|
390
|
+
}
|
|
391
|
+
const startSecs = Math.max(0, Math.min(options.startSecs ?? 0, duration));
|
|
392
|
+
const rangeDuration = Math.max(0, duration - startSecs);
|
|
393
|
+
const frameCount = Math.max(1, Math.min(Math.ceil(rangeDuration / options.intervalSecs), options.maxFrames));
|
|
394
|
+
const pattern = path.join(options.outputDir, 'frame_%04d.jpg');
|
|
395
|
+
await new Promise((resolve, reject) => {
|
|
396
|
+
const command = ffmpeg.default(videoPath);
|
|
397
|
+
if (startSecs > 0)
|
|
398
|
+
command.seekInput(startSecs);
|
|
399
|
+
command
|
|
400
|
+
.videoFilters(`select='if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${options.intervalSecs}))',scale='min(1280,iw)':-2`)
|
|
401
|
+
.outputOptions([
|
|
402
|
+
'-vsync vfr',
|
|
403
|
+
`-frames:v ${frameCount}`,
|
|
404
|
+
...(options.threads ? [`-threads ${options.threads}`] : []),
|
|
405
|
+
])
|
|
406
|
+
.output(pattern)
|
|
407
|
+
.on('progress', (progress) => {
|
|
408
|
+
if (typeof progress.percent === 'number') {
|
|
409
|
+
options.onProgress?.(Math.min(1, progress.percent / 100));
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
.on('end', () => resolve())
|
|
413
|
+
.on('error', (err) => reject(err))
|
|
414
|
+
.run();
|
|
415
|
+
});
|
|
416
|
+
const files = (await fs.readdir(options.outputDir))
|
|
417
|
+
.filter((name) => /^frame_\d+\.jpg$/.test(name))
|
|
418
|
+
.sort()
|
|
419
|
+
.slice(0, frameCount);
|
|
420
|
+
return files.map((name, index) => ({
|
|
421
|
+
path: path.join(options.outputDir, name),
|
|
422
|
+
timestampSecs: startSecs + index * options.intervalSecs,
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
/* ------------------------------------------------------------------ */
|
|
426
|
+
/* Internal helpers */
|
|
427
|
+
/* ------------------------------------------------------------------ */
|
|
428
|
+
async function importFfmpeg() {
|
|
429
|
+
const mod = await import('fluent-ffmpeg');
|
|
430
|
+
return mod;
|
|
431
|
+
}
|
|
432
|
+
function extractAudio(ffmpeg, videoPath, outputPath, threads, onProgress) {
|
|
433
|
+
return new Promise((resolve, reject) => {
|
|
434
|
+
let cmd = ffmpeg
|
|
435
|
+
.default(videoPath)
|
|
436
|
+
.noVideo()
|
|
437
|
+
.audioChannels(1)
|
|
438
|
+
.audioFrequency(16000)
|
|
439
|
+
.format('wav');
|
|
440
|
+
if (threads) {
|
|
441
|
+
cmd = cmd.outputOptions([`-threads ${threads}`]);
|
|
442
|
+
}
|
|
443
|
+
cmd
|
|
444
|
+
.output(outputPath)
|
|
445
|
+
.on('progress', (progress) => {
|
|
446
|
+
if (typeof progress.percent === 'number') {
|
|
447
|
+
onProgress?.(Math.min(1, progress.percent / 100));
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
.on('end', () => resolve())
|
|
451
|
+
.on('error', (err) => reject(err))
|
|
452
|
+
.run();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
function probeVideo(ffmpeg, videoPath) {
|
|
456
|
+
return new Promise((resolve, reject) => {
|
|
457
|
+
ffmpeg.default.ffprobe(videoPath, (err, data) => {
|
|
458
|
+
if (err)
|
|
459
|
+
return reject(err);
|
|
460
|
+
const videoStream = data.streams?.find((s) => s.codec_type === 'video');
|
|
461
|
+
resolve({
|
|
462
|
+
durationSecs: parseFloat(data.format?.duration ?? '0'),
|
|
463
|
+
width: videoStream?.width ?? 0,
|
|
464
|
+
height: videoStream?.height ?? 0,
|
|
465
|
+
codec: videoStream?.codec_name ?? 'unknown',
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@larkup/tool-video-audio",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"description": "Larkup marketplace tool: video & audio indexing with transcription and frame analysis.",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"tool.manifest.json",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
26
|
+
"fluent-ffmpeg": "^2.1.3"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@larkup/marketplace": ">=0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@larkup/marketplace": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/fluent-ffmpeg": "^2.1.27",
|
|
38
|
+
"typescript": "5.7.3",
|
|
39
|
+
"@larkup/marketplace": "0.1.1"
|
|
40
|
+
},
|
|
41
|
+
"license": "Apache-2.0",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/Larkup-AI/larkup-rag",
|
|
45
|
+
"directory": "packages/tools/video-audio"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc --outDir dist",
|
|
49
|
+
"type-check": "tsc --noEmit"
|
|
50
|
+
}
|
|
51
|
+
}
|