@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,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
+ }