@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.
@@ -0,0 +1,687 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Video processing pipeline.
6
+ *
7
+ * Uses ffmpeg (via fluent-ffmpeg) to:
8
+ * 1. Extract audio track → send to audio-processor for transcription
9
+ * 2. Extract keyframes at configurable intervals
10
+ * 3. Generate thumbnails for each keyframe
11
+ *
12
+ * The caller (API route) handles Vision LLM captioning of frames.
13
+ */
14
+
15
+ export interface VideoProcessResult {
16
+ /** Path to extracted audio file (WAV, 16kHz mono) */
17
+ audioPath?: string;
18
+ /** Extracted keyframe image paths with timestamps */
19
+ frames: { path: string; timestampSecs: number }[];
20
+ /** Video metadata */
21
+ meta: {
22
+ durationSecs: number;
23
+ width: number;
24
+ height: number;
25
+ codec: string;
26
+ };
27
+ }
28
+
29
+ export interface VideoProcessOptions {
30
+ /** Output directory for extracted files */
31
+ outputDir: string;
32
+ /** Extract one frame every N seconds (default: 10) */
33
+ frameIntervalSecs?: number;
34
+ /** Maximum number of frames to extract (default: 100) */
35
+ maxFrames?: number;
36
+ /** Scene detection sensitivity (default: 0.3; lower detects more changes) */
37
+ sceneThreshold?: number;
38
+ /** Maximum number of threads for FFmpeg to use */
39
+ threads?: number;
40
+ /** Whether to run parallel audio and frame extraction */
41
+ parallelExtraction?: boolean;
42
+ /** Skip audio extraction when an authoritative source transcript is available. */
43
+ skipAudioExtraction?: boolean;
44
+ /** Reports actual FFmpeg extraction progress as a value between 0 and 1. */
45
+ onProgress?: (progress: number) => void;
46
+ }
47
+
48
+ export interface VideoSamplingPlan {
49
+ /** Normalized video duration used to calculate the plan. */
50
+ durationSecs: number;
51
+ /** Hard ceiling for all extracted frames combined. */
52
+ maxFrames: number;
53
+ /** Cadence used for uniformly distributed coverage frames. */
54
+ periodicIntervalSecs: number;
55
+ /** Maximum number of uniformly distributed coverage frames. */
56
+ periodicFrameCount: number;
57
+ /** Maximum number of additional scene-change frames. */
58
+ sceneFrameCount: number;
59
+ /** Frames reserved for a denser look at the end of the recording. */
60
+ endingFrameCount: number;
61
+ /** Minimum time between selected scene changes. */
62
+ minimumSceneGapSecs: number;
63
+ /** Maximum periodic + scene + ending frames before de-duplication. */
64
+ estimatedFrameCount: number;
65
+ }
66
+
67
+ export interface EndingSamplingPlan {
68
+ startSecs: number;
69
+ intervalSecs: number;
70
+ frameCount: number;
71
+ timestamps: number[];
72
+ }
73
+
74
+ export interface TimedText {
75
+ text: string;
76
+ startSecs: number;
77
+ endSecs: number;
78
+ }
79
+
80
+ export interface MultimodalSegment extends TimedText {
81
+ transcript: string;
82
+ visualContext: string;
83
+ sequence: number;
84
+ }
85
+
86
+ /**
87
+ * Fuse speech and visual observations onto one timeline. Each searchable unit
88
+ * contains everything that happened in the same time window instead of an
89
+ * unrelated transcript or frame caption.
90
+ */
91
+ export function buildMultimodalSegments(
92
+ transcript: TimedText[],
93
+ visuals: TimedText[],
94
+ durationSecs: number,
95
+ targetWindowSecs = 60,
96
+ ): MultimodalSegment[] {
97
+ const evidence = [...transcript, ...visuals];
98
+ if (evidence.length === 0) return [];
99
+
100
+ const knownEnd = Math.max(durationSecs, ...evidence.map((item) => item.endSecs));
101
+ const segments: MultimodalSegment[] = [];
102
+
103
+ for (let startSecs = 0; startSecs < knownEnd; startSecs += targetWindowSecs) {
104
+ const endSecs = Math.min(startSecs + targetWindowSecs, knownEnd);
105
+ const overlaps = (item: TimedText) => item.startSecs < endSecs && item.endSecs > startSecs;
106
+ const spoken = transcript
107
+ .filter(overlaps)
108
+ .map((item) => item.text.trim())
109
+ .filter(Boolean);
110
+ const seen = visuals
111
+ .filter(overlaps)
112
+ .map((item) => item.text.trim())
113
+ .filter(Boolean);
114
+ if (spoken.length === 0 && seen.length === 0) continue;
115
+
116
+ const transcriptText = [...new Set(spoken)].join(' ');
117
+ const visualContext = [...new Set(seen)].join(' ');
118
+ const parts = [
119
+ `Timeline: ${formatTimestamp(startSecs)}–${formatTimestamp(endSecs)}.`,
120
+ transcriptText ? `Speech: ${transcriptText}` : '',
121
+ visualContext ? `Visual sequence, actions, and on-screen text: ${visualContext}` : '',
122
+ ].filter(Boolean);
123
+
124
+ segments.push({
125
+ text: parts.join('\n'),
126
+ transcript: transcriptText,
127
+ visualContext,
128
+ startSecs,
129
+ endSecs,
130
+ sequence: Math.floor(startSecs / targetWindowSecs),
131
+ });
132
+ }
133
+
134
+ return segments;
135
+ }
136
+
137
+ function formatTimestamp(seconds: number): string {
138
+ const hours = Math.floor(seconds / 3600);
139
+ const minutes = Math.floor((seconds % 3600) / 60);
140
+ const secs = Math.floor(seconds % 60);
141
+ return hours > 0
142
+ ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
143
+ : `${minutes}:${String(secs).padStart(2, '0')}`;
144
+ }
145
+
146
+ /**
147
+ * Create a bounded frame-sampling plan without treating maxFrames as a quota.
148
+ *
149
+ * Edited videos receive frequent anchors and scene-change coverage. As duration
150
+ * grows, the anchor cadence widens so multi-hour camera and screen recordings
151
+ * remain searchable without generating hundreds of near-identical frames.
152
+ */
153
+ export function createVideoSamplingPlan(
154
+ durationSecs: number,
155
+ configuredInterval = 30,
156
+ maxFrames = 100,
157
+ ): VideoSamplingPlan {
158
+ const duration = Number.isFinite(durationSecs) ? Math.max(0, durationSecs) : 0;
159
+ const hardLimit = Number.isFinite(maxFrames) ? Math.max(1, Math.floor(maxFrames)) : 100;
160
+ const requestedInterval =
161
+ Number.isFinite(configuredInterval) && configuredInterval > 0 ? configuredInterval : 30;
162
+
163
+ let periodicFloorSecs: number;
164
+ let sceneCadenceSecs: number;
165
+ if (duration <= 15 * 60) {
166
+ periodicFloorSecs = 15;
167
+ sceneCadenceSecs = 15;
168
+ } else if (duration <= 60 * 60) {
169
+ periodicFloorSecs = 30;
170
+ sceneCadenceSecs = 30;
171
+ } else if (duration <= 4 * 60 * 60) {
172
+ periodicFloorSecs = 60;
173
+ sceneCadenceSecs = 60;
174
+ } else if (duration <= 12 * 60 * 60) {
175
+ periodicFloorSecs = 10 * 60;
176
+ sceneCadenceSecs = 10 * 60;
177
+ } else {
178
+ periodicFloorSecs = 5 * 60;
179
+ sceneCadenceSecs = 5 * 60;
180
+ }
181
+
182
+ const preferredInterval = Math.max(requestedInterval, periodicFloorSecs);
183
+ const desiredPeriodicCount = Math.max(1, Math.ceil(duration / preferredInterval));
184
+ const desiredSceneCount = duration > 0 ? Math.max(1, Math.ceil(duration / sceneCadenceSecs)) : 0;
185
+ const desiredTotal = desiredPeriodicCount + desiredSceneCount;
186
+ const desiredEndingCount =
187
+ duration >= 60 ? Math.ceil(Math.min(90, duration) / (duration <= 2 * 60 * 60 ? 5 : 15)) : 0;
188
+ const endingFrameCount =
189
+ desiredEndingCount > 0
190
+ ? Math.min(
191
+ desiredEndingCount,
192
+ hardLimit === 1 ? 1 : Math.max(1, Math.min(hardLimit - 1, Math.floor(hardLimit * 0.2))),
193
+ )
194
+ : 0;
195
+ const coverageLimit = Math.max(0, hardLimit - endingFrameCount);
196
+
197
+ let periodicFrameCount: number;
198
+ let sceneFrameCount: number;
199
+ if (coverageLimit === 0) {
200
+ periodicFrameCount = 0;
201
+ sceneFrameCount = 0;
202
+ } else if (desiredTotal <= coverageLimit) {
203
+ periodicFrameCount = desiredPeriodicCount;
204
+ sceneFrameCount = desiredSceneCount;
205
+ } else if (coverageLimit === 1 || desiredSceneCount === 0) {
206
+ periodicFrameCount = 1;
207
+ sceneFrameCount = 0;
208
+ } else {
209
+ // Preserve both uniform and change-based evidence when the natural plan
210
+ // exceeds the hard limit, in proportion to the useful frames each needs.
211
+ periodicFrameCount = Math.max(
212
+ 1,
213
+ Math.min(
214
+ coverageLimit - 1,
215
+ Math.round((coverageLimit * desiredPeriodicCount) / desiredTotal),
216
+ ),
217
+ );
218
+ sceneFrameCount = coverageLimit - periodicFrameCount;
219
+ }
220
+
221
+ const periodicIntervalSecs =
222
+ periodicFrameCount > 0 && periodicFrameCount < desiredPeriodicCount && duration > 0
223
+ ? Math.max(preferredInterval, duration / periodicFrameCount)
224
+ : preferredInterval;
225
+ const minimumSceneGapSecs =
226
+ sceneFrameCount > 0 ? Math.max(1, periodicIntervalSecs / 2, duration / sceneFrameCount) : 0;
227
+
228
+ return {
229
+ durationSecs: duration,
230
+ maxFrames: hardLimit,
231
+ periodicIntervalSecs,
232
+ periodicFrameCount,
233
+ sceneFrameCount,
234
+ endingFrameCount,
235
+ minimumSceneGapSecs,
236
+ estimatedFrameCount: periodicFrameCount + sceneFrameCount + endingFrameCount,
237
+ };
238
+ }
239
+
240
+ /** Densely sample the ending where final results and decisions commonly appear. */
241
+ export function createEndingSamplingPlan(
242
+ durationSecs: number,
243
+ maxAdditionalFrames: number,
244
+ ): EndingSamplingPlan {
245
+ const duration = Number.isFinite(durationSecs) ? Math.max(0, durationSecs) : 0;
246
+ const capacity = Number.isFinite(maxAdditionalFrames)
247
+ ? Math.max(0, Math.floor(maxAdditionalFrames))
248
+ : 0;
249
+ if (duration < 60 || capacity === 0) {
250
+ return {
251
+ startSecs: Math.max(0, duration - 90),
252
+ intervalSecs: 5,
253
+ frameCount: 0,
254
+ timestamps: [],
255
+ };
256
+ }
257
+ const windowSecs = Math.min(90, duration);
258
+ const preferredIntervalSecs = duration <= 2 * 60 * 60 ? 5 : 15;
259
+ const desiredFrameCount = Math.ceil(windowSecs / preferredIntervalSecs);
260
+ const frameCount = Math.min(capacity, desiredFrameCount);
261
+ const windowStart = Math.max(0, duration - windowSecs);
262
+ const endSecs = Math.max(windowStart, duration - Math.min(1, preferredIntervalSecs));
263
+ const hasFullCadence = frameCount === desiredFrameCount;
264
+ const startSecs =
265
+ frameCount === 1 ? Math.max(windowStart, duration - preferredIntervalSecs) : windowStart;
266
+ const intervalSecs = hasFullCadence
267
+ ? preferredIntervalSecs
268
+ : frameCount > 1
269
+ ? Math.max(1, (endSecs - startSecs) / (frameCount - 1))
270
+ : preferredIntervalSecs;
271
+ return {
272
+ startSecs,
273
+ intervalSecs,
274
+ frameCount,
275
+ timestamps: Array.from({ length: frameCount }, (_, index) => startSecs + index * intervalSecs),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Process a video file: extract audio and keyframes.
281
+ */
282
+ export async function processVideo(
283
+ videoPath: string,
284
+ options: VideoProcessOptions,
285
+ ): Promise<VideoProcessResult> {
286
+ const ffmpeg = await importFfmpeg();
287
+
288
+ await fs.mkdir(options.outputDir, { recursive: true });
289
+ const framesDir = path.join(options.outputDir, 'frames');
290
+ await fs.mkdir(framesDir, { recursive: true });
291
+
292
+ // 1. Probe video metadata
293
+ const meta = await probeVideo(ffmpeg, videoPath);
294
+
295
+ const audioPath = path.join(options.outputDir, 'audio.wav');
296
+ const interval = options.frameIntervalSecs ?? 10;
297
+ const maxFrames = options.maxFrames ?? 100;
298
+ let audioProgress = options.skipAudioExtraction ? 1 : 0;
299
+ let frameProgress = 0;
300
+ const reportExtractionProgress = () => {
301
+ options.onProgress?.(
302
+ options.skipAudioExtraction ? frameProgress : (audioProgress + frameProgress) / 2,
303
+ );
304
+ };
305
+
306
+ // Cameras and screen recordings do not always have an audio stream. Visual
307
+ // indexing must still succeed for those files.
308
+ const extractAudioPromise = options.skipAudioExtraction
309
+ ? Promise.resolve(undefined)
310
+ : extractAudio(ffmpeg, videoPath, audioPath, options.threads, (progress) => {
311
+ audioProgress = Math.max(audioProgress, progress);
312
+ reportExtractionProgress();
313
+ })
314
+ .then(() => {
315
+ audioProgress = 1;
316
+ reportExtractionProgress();
317
+ return audioPath;
318
+ })
319
+ .catch(() => {
320
+ // Videos without an audio stream remain valid visual-indexing input.
321
+ audioProgress = 1;
322
+ reportExtractionProgress();
323
+ return undefined;
324
+ });
325
+ const extractFramesPromise = extractSceneFrames(videoPath, {
326
+ outputDir: framesDir,
327
+ intervalSecs: interval,
328
+ maxFrames,
329
+ durationSecs: meta.durationSecs,
330
+ sceneThreshold: options.sceneThreshold,
331
+ threads: options.threads,
332
+ onProgress: (progress) => {
333
+ frameProgress = Math.max(frameProgress, progress);
334
+ reportExtractionProgress();
335
+ },
336
+ });
337
+
338
+ let frames: { path: string; timestampSecs: number }[];
339
+ let extractedAudioPath: string | undefined;
340
+ if (options.parallelExtraction) {
341
+ [extractedAudioPath, frames] = await Promise.all([extractAudioPromise, extractFramesPromise]);
342
+ } else {
343
+ extractedAudioPath = await extractAudioPromise;
344
+ frames = await extractFramesPromise;
345
+ }
346
+
347
+ return { audioPath: extractedAudioPath, frames, meta };
348
+ }
349
+
350
+ /** Extract scene-change frames in one ffmpeg process, with adaptive interval fallback. */
351
+ export async function extractSceneFrames(
352
+ videoPath: string,
353
+ options: {
354
+ outputDir: string;
355
+ maxFrames: number;
356
+ durationSecs?: number;
357
+ intervalSecs?: number;
358
+ sceneThreshold?: number;
359
+ threads?: number;
360
+ onProgress?: (progress: number) => void;
361
+ },
362
+ ): Promise<{ path: string; timestampSecs: number }[]> {
363
+ const ffmpeg = await importFfmpeg();
364
+ await fs.mkdir(options.outputDir, { recursive: true });
365
+ const duration = options.durationSecs || (await probeVideo(ffmpeg, videoPath)).durationSecs;
366
+ const plan = createVideoSamplingPlan(duration, options.intervalSecs, options.maxFrames);
367
+ const totalPlannedWork = Math.max(1, plan.estimatedFrameCount);
368
+ const periodicWeight = plan.periodicFrameCount / totalPlannedWork;
369
+ const sceneWeight = plan.sceneFrameCount / totalPlannedWork;
370
+ const endingWeight = plan.endingFrameCount / totalPlannedWork;
371
+ let reportedProgress = 0;
372
+ const reportProgress = (progress: number) => {
373
+ reportedProgress = Math.max(reportedProgress, Math.max(0, Math.min(1, progress)));
374
+ options.onProgress?.(reportedProgress);
375
+ };
376
+ const addEndingEvidence = async (
377
+ frames: { path: string; timestampSecs: number }[],
378
+ ): Promise<{ path: string; timestampSecs: number }[]> => {
379
+ const remaining = Math.max(0, plan.maxFrames - frames.length);
380
+ const endingPlan = createEndingSamplingPlan(
381
+ duration,
382
+ Math.min(remaining, plan.endingFrameCount),
383
+ );
384
+ if (endingPlan.frameCount === 0) {
385
+ reportProgress(1);
386
+ return frames.slice(0, plan.maxFrames);
387
+ }
388
+
389
+ // Outcomes, decisions, and final scoreboard animations often change several
390
+ // times within a few seconds. A short ending burst complements the sparse
391
+ // whole-video plan without turning the frame ceiling into a quota.
392
+ const endingFrames = await extractFrames(videoPath, {
393
+ outputDir: path.join(options.outputDir, 'ending'),
394
+ intervalSecs: endingPlan.intervalSecs,
395
+ maxFrames: endingPlan.frameCount,
396
+ durationSecs: duration,
397
+ startSecs: endingPlan.startSecs,
398
+ threads: options.threads,
399
+ onProgress: (progress) =>
400
+ reportProgress(periodicWeight + sceneWeight + progress * endingWeight),
401
+ });
402
+ const merged = [...frames, ...endingFrames].sort(
403
+ (left, right) => left.timestampSecs - right.timestampSecs,
404
+ );
405
+ const result = merged
406
+ .filter(
407
+ (frame, index) =>
408
+ index === 0 || Math.abs(frame.timestampSecs - merged[index - 1].timestampSecs) >= 1,
409
+ )
410
+ .slice(0, plan.maxFrames);
411
+ reportProgress(1);
412
+ return result;
413
+ };
414
+
415
+ // Uniform frames guarantee beginning-to-end coverage, while the independent
416
+ // scene pass captures meaningful changes between those anchors.
417
+ const periodicFrames =
418
+ plan.periodicFrameCount === 0
419
+ ? []
420
+ : duration >= 2 * 60 * 60
421
+ ? await extractFramesAtTimestamps(
422
+ ffmpeg,
423
+ videoPath,
424
+ Array.from({ length: plan.periodicFrameCount }, (_, index) =>
425
+ Math.min(Math.max(0, duration - 1), index * plan.periodicIntervalSecs),
426
+ ),
427
+ {
428
+ outputDir: options.outputDir,
429
+ threads: options.threads,
430
+ onProgress: (progress) => reportProgress(progress * periodicWeight),
431
+ },
432
+ )
433
+ : await extractFrames(videoPath, {
434
+ outputDir: options.outputDir,
435
+ intervalSecs: plan.periodicIntervalSecs,
436
+ maxFrames: plan.periodicFrameCount,
437
+ durationSecs: duration,
438
+ threads: options.threads,
439
+ onProgress: (progress) => reportProgress(progress * periodicWeight),
440
+ });
441
+ if (plan.sceneFrameCount === 0) return addEndingEvidence(periodicFrames);
442
+
443
+ const threshold = options.sceneThreshold ?? 0.3;
444
+ const sceneDir = path.join(options.outputDir, 'scenes');
445
+ await fs.mkdir(sceneDir, { recursive: true });
446
+ const pattern = path.join(sceneDir, 'scene_%04d.jpg');
447
+ let timestamps: number[] = [];
448
+ await new Promise<void>((resolve) => {
449
+ const command = ffmpeg.default(videoPath);
450
+ const isLongRecording = duration >= 2 * 60 * 60;
451
+ if (isLongRecording) {
452
+ // Long surveillance/screen recordings are commonly mostly static. Decode
453
+ // keyframes only and score changes at a smaller resolution so an 8-hour
454
+ // input does not perform full-resolution scene math on every source frame.
455
+ command.inputOptions(['-skip_frame nokey']);
456
+ }
457
+ command
458
+ .videoFilters(
459
+ isLongRecording
460
+ ? `scale='min(640,iw)':-2,select='gt(scene,${threshold})*if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${plan.minimumSceneGapSecs}))',showinfo`
461
+ : `select='gt(scene,${threshold})*if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${plan.minimumSceneGapSecs}))',showinfo,scale='min(1280,iw)':-2`,
462
+ )
463
+ .outputOptions([
464
+ '-vsync vfr',
465
+ `-frames:v ${plan.sceneFrameCount}`,
466
+ ...(options.threads ? [`-threads ${options.threads}`] : []),
467
+ ])
468
+ .output(pattern)
469
+ .on('stderr', (line: string) => {
470
+ const match = line.match(/pts_time:([0-9.]+)/);
471
+ if (match) timestamps.push(Number(match[1]));
472
+ })
473
+ .on('progress', (progress: { percent?: number }) => {
474
+ if (typeof progress.percent === 'number') {
475
+ reportProgress(periodicWeight + Math.min(1, progress.percent / 100) * sceneWeight);
476
+ }
477
+ })
478
+ .on('end', () => resolve())
479
+ .on('error', () => {
480
+ // Scene detection is supplementary. Some ffmpeg versions report an
481
+ // encoder error when the selector legitimately emits zero frames.
482
+ // Uniform coverage frames must still make the video indexable.
483
+ resolve();
484
+ })
485
+ .run();
486
+ });
487
+ reportProgress(periodicWeight + sceneWeight);
488
+ const files = (await fs.readdir(sceneDir))
489
+ .filter((name) => /^scene_\d+\.jpg$/.test(name))
490
+ .sort()
491
+ .slice(0, plan.sceneFrameCount);
492
+ if (!files.length) return addEndingEvidence(periodicFrames);
493
+ timestamps = timestamps.slice(-files.length);
494
+ const sceneFrames = files.map((name, index) => ({
495
+ path: path.join(sceneDir, name),
496
+ timestampSecs: timestamps[index] ?? (index * duration) / files.length,
497
+ }));
498
+
499
+ const merged = [...periodicFrames, ...sceneFrames].sort(
500
+ (left, right) => left.timestampSecs - right.timestampSecs,
501
+ );
502
+ const deduplicated = merged.filter(
503
+ (frame, index) =>
504
+ index === 0 || Math.abs(frame.timestampSecs - merged[index - 1].timestampSecs) >= 1,
505
+ );
506
+ return addEndingEvidence(deduplicated);
507
+ }
508
+
509
+ /**
510
+ * Use input seeking for sparse anchors in multi-hour media. This avoids decoding
511
+ * the entire recording once merely to retain a few dozen periodic frames.
512
+ */
513
+ async function extractFramesAtTimestamps(
514
+ ffmpeg: Awaited<ReturnType<typeof importFfmpeg>>,
515
+ videoPath: string,
516
+ timestamps: number[],
517
+ options: {
518
+ outputDir: string;
519
+ threads?: number;
520
+ onProgress?: (progress: number) => void;
521
+ },
522
+ ): Promise<{ path: string; timestampSecs: number }[]> {
523
+ await fs.mkdir(options.outputDir, { recursive: true });
524
+ const results = new Array<{ path: string; timestampSecs: number }>(timestamps.length);
525
+ let cursor = 0;
526
+ let completed = 0;
527
+
528
+ await Promise.all(
529
+ Array.from({ length: Math.min(2, timestamps.length) }, async () => {
530
+ while (cursor < timestamps.length) {
531
+ const index = cursor++;
532
+ const timestampSecs = timestamps[index];
533
+ const outputPath = path.join(
534
+ options.outputDir,
535
+ `frame_${String(index + 1).padStart(4, '0')}.jpg`,
536
+ );
537
+ await new Promise<void>((resolve, reject) => {
538
+ ffmpeg
539
+ .default(videoPath)
540
+ .seekInput(timestampSecs)
541
+ .videoFilters(`scale='min(1280,iw)':-2`)
542
+ .outputOptions([
543
+ '-frames:v 1',
544
+ ...(options.threads ? [`-threads ${options.threads}`] : []),
545
+ ])
546
+ .output(outputPath)
547
+ .on('end', () => resolve())
548
+ .on('error', (error: Error) => reject(error))
549
+ .run();
550
+ });
551
+ results[index] = { path: outputPath, timestampSecs };
552
+ completed += 1;
553
+ options.onProgress?.(completed / timestamps.length);
554
+ }
555
+ }),
556
+ );
557
+
558
+ return results;
559
+ }
560
+
561
+ /**
562
+ * Extract keyframes from a video at regular intervals.
563
+ */
564
+ export async function extractFrames(
565
+ videoPath: string,
566
+ options: {
567
+ outputDir: string;
568
+ intervalSecs: number;
569
+ maxFrames: number;
570
+ durationSecs?: number;
571
+ /** Optional range start, used for denser ending/outcome evidence. */
572
+ startSecs?: number;
573
+ threads?: number;
574
+ onProgress?: (progress: number) => void;
575
+ },
576
+ ): Promise<{ path: string; timestampSecs: number }[]> {
577
+ const ffmpeg = await importFfmpeg();
578
+ await fs.mkdir(options.outputDir, { recursive: true });
579
+
580
+ // Get duration if not provided
581
+ let duration = options.durationSecs;
582
+ if (!duration) {
583
+ const meta = await probeVideo(ffmpeg, videoPath);
584
+ duration = meta.durationSecs;
585
+ }
586
+
587
+ const startSecs = Math.max(0, Math.min(options.startSecs ?? 0, duration));
588
+ const rangeDuration = Math.max(0, duration - startSecs);
589
+ const frameCount = Math.max(
590
+ 1,
591
+ Math.min(Math.ceil(rangeDuration / options.intervalSecs), options.maxFrames),
592
+ );
593
+ const pattern = path.join(options.outputDir, 'frame_%04d.jpg');
594
+ await new Promise<void>((resolve, reject) => {
595
+ const command = ffmpeg.default(videoPath);
596
+ if (startSecs > 0) command.seekInput(startSecs);
597
+ command
598
+ .videoFilters(
599
+ `select='if(isnan(prev_selected_t),1,gte(t-prev_selected_t,${options.intervalSecs}))',scale='min(1280,iw)':-2`,
600
+ )
601
+ .outputOptions([
602
+ '-vsync vfr',
603
+ `-frames:v ${frameCount}`,
604
+ ...(options.threads ? [`-threads ${options.threads}`] : []),
605
+ ])
606
+ .output(pattern)
607
+ .on('progress', (progress: { percent?: number }) => {
608
+ if (typeof progress.percent === 'number') {
609
+ options.onProgress?.(Math.min(1, progress.percent / 100));
610
+ }
611
+ })
612
+ .on('end', () => resolve())
613
+ .on('error', (err: Error) => reject(err))
614
+ .run();
615
+ });
616
+ const files = (await fs.readdir(options.outputDir))
617
+ .filter((name) => /^frame_\d+\.jpg$/.test(name))
618
+ .sort()
619
+ .slice(0, frameCount);
620
+ return files.map((name, index) => ({
621
+ path: path.join(options.outputDir, name),
622
+ timestampSecs: startSecs + index * options.intervalSecs,
623
+ }));
624
+ }
625
+
626
+ /* ------------------------------------------------------------------ */
627
+ /* Internal helpers */
628
+ /* ------------------------------------------------------------------ */
629
+
630
+ async function importFfmpeg() {
631
+ const mod = await import('fluent-ffmpeg');
632
+ return mod;
633
+ }
634
+
635
+ function extractAudio(
636
+ ffmpeg: any,
637
+ videoPath: string,
638
+ outputPath: string,
639
+ threads?: number,
640
+ onProgress?: (progress: number) => void,
641
+ ): Promise<void> {
642
+ return new Promise((resolve, reject) => {
643
+ let cmd = ffmpeg
644
+ .default(videoPath)
645
+ .noVideo()
646
+ .audioChannels(1)
647
+ .audioFrequency(16000)
648
+ .format('wav');
649
+
650
+ if (threads) {
651
+ cmd = cmd.outputOptions([`-threads ${threads}`]);
652
+ }
653
+
654
+ cmd
655
+ .output(outputPath)
656
+ .on('progress', (progress: { percent?: number }) => {
657
+ if (typeof progress.percent === 'number') {
658
+ onProgress?.(Math.min(1, progress.percent / 100));
659
+ }
660
+ })
661
+ .on('end', () => resolve())
662
+ .on('error', (err: Error) => reject(err))
663
+ .run();
664
+ });
665
+ }
666
+
667
+ interface VideoMeta {
668
+ durationSecs: number;
669
+ width: number;
670
+ height: number;
671
+ codec: string;
672
+ }
673
+
674
+ function probeVideo(ffmpeg: any, videoPath: string): Promise<VideoMeta> {
675
+ return new Promise((resolve, reject) => {
676
+ ffmpeg.default.ffprobe(videoPath, (err: Error | null, data: any) => {
677
+ if (err) return reject(err);
678
+ const videoStream = data.streams?.find((s: any) => s.codec_type === 'video');
679
+ resolve({
680
+ durationSecs: parseFloat(data.format?.duration ?? '0'),
681
+ width: videoStream?.width ?? 0,
682
+ height: videoStream?.height ?? 0,
683
+ codec: videoStream?.codec_name ?? 'unknown',
684
+ });
685
+ });
686
+ });
687
+ }