@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,291 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream, promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { isIP } from 'node:net';
7
+ import { lookup } from 'node:dns/promises';
8
+ import { randomUUID } from 'node:crypto';
9
+ export async function inspectMediaUrl(url) {
10
+ const parsed = validHttpUrl(url);
11
+ if (isYouTube(parsed)) {
12
+ const output = await runYtDlp([
13
+ '--dump-single-json',
14
+ '--simulate',
15
+ '--flat-playlist',
16
+ '--playlist-end',
17
+ '10',
18
+ url,
19
+ ]);
20
+ const data = JSON.parse(output);
21
+ const entries = data.entries?.slice(0, 10) ?? [];
22
+ return {
23
+ originalUrl: url,
24
+ title: data.title,
25
+ durationSecs: data.duration ?? entries.reduce((sum, entry) => sum + (entry.duration ?? 0), 0),
26
+ entryCount: Math.max(entries.length, 1),
27
+ mediaType: 'video',
28
+ isYouTube: true,
29
+ };
30
+ }
31
+ let response = await fetchPublic(url, { method: 'HEAD' });
32
+ if (response.status === 405 || response.status === 501) {
33
+ response = await fetchPublic(url, { headers: { Range: 'bytes=0-0' } });
34
+ }
35
+ if (!response.ok)
36
+ throw new Error(`Unable to inspect media URL (${response.status})`);
37
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
38
+ const mimeType = mediaTypeFromMime(headerMime) === 'unknown'
39
+ ? mimeFromExtension(path.extname(parsed.pathname).slice(1))
40
+ : headerMime;
41
+ const rangeTotal = response.headers.get('content-range')?.match(/\/(\d+)$/)?.[1];
42
+ const inspection = {
43
+ originalUrl: url,
44
+ mimeType,
45
+ mediaType: mediaTypeFromMime(mimeType),
46
+ contentLength: Number(rangeTotal ?? response.headers.get('content-length')) || undefined,
47
+ durationSecs: Number(response.headers.get('content-duration')) || undefined,
48
+ entryCount: 1,
49
+ isYouTube: false,
50
+ };
51
+ await response.body?.cancel();
52
+ return inspection;
53
+ }
54
+ export async function importMediaUrl(url, options) {
55
+ const parsed = validHttpUrl(url);
56
+ await fs.mkdir(options.outputDir, { recursive: true });
57
+ if (isYouTube(parsed)) {
58
+ const template = path.join(options.outputDir, '%(title).120B [%(id)s].%(ext)s');
59
+ const print = '{"path":%(filepath)j,"title":%(title)j,"originalUrl":%(webpage_url)j,"ext":%(ext)j}';
60
+ const output = await runYtDlp([
61
+ '--no-progress',
62
+ '--playlist-end',
63
+ String(options.playlistMax ?? 10),
64
+ '--format',
65
+ 'bestvideo[height<=480]+bestaudio/best[height<=480]/best',
66
+ '--merge-output-format',
67
+ 'mp4',
68
+ '-o',
69
+ template,
70
+ '--print',
71
+ `after_move:${print}`,
72
+ url,
73
+ ]);
74
+ const imported = output
75
+ .split('\n')
76
+ .filter(Boolean)
77
+ .map((line) => {
78
+ const item = JSON.parse(line);
79
+ return {
80
+ path: item.path,
81
+ title: item.title,
82
+ originalUrl: item.originalUrl || url,
83
+ mimeType: mimeFromExtension(item.ext),
84
+ mediaType: mediaTypeFromMime(mimeFromExtension(item.ext)),
85
+ };
86
+ });
87
+ return Promise.all(imported.map(async (item) => ({
88
+ ...item,
89
+ sourceTranscript: await fetchYouTubeTranscript(item.originalUrl).catch(() => undefined),
90
+ })));
91
+ }
92
+ const response = await fetchPublic(url);
93
+ if (!response.ok || !response.body)
94
+ throw new Error(`Media download failed (${response.status})`);
95
+ const maxBytes = options.maxBytes ?? 500 * 1024 * 1024;
96
+ const declared = Number(response.headers.get('content-length'));
97
+ if (declared > maxBytes)
98
+ throw new Error(`Media exceeds download limit of ${maxBytes} bytes`);
99
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
100
+ const rawName = path.basename(new URL(response.url).pathname) || 'download';
101
+ const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 180) || 'download';
102
+ const mimeType = mediaTypeFromMime(headerMime) === 'unknown'
103
+ ? mimeFromExtension(path.extname(name).slice(1))
104
+ : headerMime;
105
+ const outputPath = path.join(options.outputDir, `${randomUUID()}-${name}`);
106
+ let bytes = 0;
107
+ const body = Readable.fromWeb(response.body);
108
+ body.on('data', (chunk) => {
109
+ bytes += chunk.length;
110
+ if (bytes > maxBytes)
111
+ body.destroy(new Error(`Media exceeds download limit of ${maxBytes} bytes`));
112
+ });
113
+ try {
114
+ await pipeline(body, createWriteStream(outputPath, { flags: 'wx' }));
115
+ }
116
+ catch (error) {
117
+ await fs.rm(outputPath, { force: true });
118
+ throw error;
119
+ }
120
+ return [
121
+ {
122
+ path: outputPath,
123
+ originalUrl: url,
124
+ title: name,
125
+ mimeType,
126
+ mediaType: mediaTypeFromMime(mimeType),
127
+ },
128
+ ];
129
+ }
130
+ /**
131
+ * Read the video's own manual or automatic captions through yt-dlp. This is
132
+ * both faster and usually more accurate than transcribing compressed audio,
133
+ * especially for names and non-English speech.
134
+ */
135
+ async function fetchYouTubeTranscript(url) {
136
+ const output = await runYtDlp(['--dump-single-json', '--skip-download', '--no-playlist', url]);
137
+ const data = JSON.parse(output);
138
+ const manualTrack = selectSubtitleTrack(data.subtitles, data.language);
139
+ const selected = manualTrack ?? selectSubtitleTrack(data.automatic_captions, data.language);
140
+ if (!selected)
141
+ return undefined;
142
+ const response = await fetch(selected.url);
143
+ if (!response.ok)
144
+ throw new Error(`YouTube captions download failed (${response.status})`);
145
+ const transcript = parseYouTubeJson3Transcript(await response.json(), data.duration ?? 0);
146
+ return transcript.chunks.length > 0
147
+ ? {
148
+ ...transcript,
149
+ language: selected.language,
150
+ origin: {
151
+ kind: manualTrack ? 'youtube-manual' : 'youtube-auto',
152
+ language: selected.language,
153
+ },
154
+ }
155
+ : undefined;
156
+ }
157
+ function selectSubtitleTrack(tracks, language) {
158
+ if (!tracks)
159
+ return undefined;
160
+ const languages = Object.keys(tracks).filter((key) => key !== 'live_chat');
161
+ const baseLanguage = language?.split('-')[0];
162
+ const preferredLanguages = [
163
+ language,
164
+ language ? `${language}-orig` : undefined,
165
+ baseLanguage,
166
+ baseLanguage ? `${baseLanguage}-orig` : undefined,
167
+ ...languages.filter((key) => key.endsWith('-orig')),
168
+ ...languages,
169
+ ].filter((key) => Boolean(key));
170
+ for (const key of [...new Set(preferredLanguages)]) {
171
+ const format = tracks[key]?.find((candidate) => candidate.ext === 'json3' && candidate.url);
172
+ if (format?.url)
173
+ return { url: format.url, language: key.replace(/-orig$/, '') };
174
+ }
175
+ return undefined;
176
+ }
177
+ /** Parse YouTube's timestamped json3 caption format into indexing chunks. */
178
+ export function parseYouTubeJson3Transcript(data, durationSecs = 0, chunkDurationSecs = 30) {
179
+ const cues = (data.events ?? [])
180
+ .map((event) => ({
181
+ text: (event.segs ?? [])
182
+ .map((segment) => segment.utf8 ?? '')
183
+ .join('')
184
+ .replace(/\s+/g, ' ')
185
+ .trim(),
186
+ startSecs: Math.max(0, Number(event.tStartMs ?? 0) / 1_000),
187
+ endSecs: Math.max(0, Number(event.tStartMs ?? 0) / 1_000 + Number(event.dDurationMs ?? 0) / 1_000),
188
+ }))
189
+ .filter((cue) => cue.text);
190
+ const chunks = [];
191
+ for (const cue of cues) {
192
+ const current = chunks.at(-1);
193
+ if (!current || cue.startSecs - current.startSecs >= chunkDurationSecs) {
194
+ chunks.push({ ...cue });
195
+ }
196
+ else {
197
+ current.text = `${current.text} ${cue.text}`;
198
+ current.endSecs = Math.max(current.endSecs, cue.endSecs);
199
+ }
200
+ }
201
+ return {
202
+ fullText: chunks.map((chunk) => chunk.text).join(' '),
203
+ chunks,
204
+ durationSecs: Math.max(durationSecs, chunks.at(-1)?.endSecs ?? 0),
205
+ };
206
+ }
207
+ function validHttpUrl(value) {
208
+ const url = new URL(value);
209
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
210
+ throw new Error('Only http(s) media URLs are supported');
211
+ return url;
212
+ }
213
+ async function fetchPublic(url, init = {}) {
214
+ let current = validHttpUrl(url);
215
+ for (let redirects = 0; redirects <= 5; redirects++) {
216
+ await assertPublicHost(current.hostname);
217
+ const response = await fetch(current, { ...init, redirect: 'manual' });
218
+ if (![301, 302, 303, 307, 308].includes(response.status))
219
+ return response;
220
+ const location = response.headers.get('location');
221
+ if (!location)
222
+ throw new Error('Media URL redirected without a location');
223
+ current = validHttpUrl(new URL(location, current).toString());
224
+ }
225
+ throw new Error('Media URL redirected too many times');
226
+ }
227
+ async function assertPublicHost(hostname) {
228
+ const addresses = isIP(hostname)
229
+ ? [{ address: hostname }]
230
+ : await lookup(hostname, { all: true, verbatim: true });
231
+ if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
232
+ throw new Error('Private or local media URLs are not supported');
233
+ }
234
+ }
235
+ function isPrivateAddress(address) {
236
+ const normalized = address.toLowerCase();
237
+ if (normalized === '::1' || normalized === '::' || normalized.startsWith('fe80:'))
238
+ return true;
239
+ if (normalized.startsWith('fc') || normalized.startsWith('fd'))
240
+ return true;
241
+ const ipv4 = normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized;
242
+ const parts = ipv4.split('.').map(Number);
243
+ if (parts.length !== 4 || parts.some(Number.isNaN))
244
+ return false;
245
+ const [a, b] = parts;
246
+ return (a === 0 ||
247
+ a === 10 ||
248
+ a === 127 ||
249
+ (a === 169 && b === 254) ||
250
+ (a === 172 && b >= 16 && b <= 31) ||
251
+ (a === 192 && b === 168) ||
252
+ a >= 224);
253
+ }
254
+ function isYouTube(url) {
255
+ return /(^|\.)youtube\.com$|(^|\.)youtu\.be$/.test(url.hostname);
256
+ }
257
+ function mediaTypeFromMime(mime) {
258
+ return mime?.startsWith('video/') ? 'video' : mime?.startsWith('audio/') ? 'audio' : 'unknown';
259
+ }
260
+ function mimeFromExtension(ext) {
261
+ return {
262
+ mp4: 'video/mp4',
263
+ webm: 'video/webm',
264
+ mkv: 'video/x-matroska',
265
+ mov: 'video/quicktime',
266
+ mp3: 'audio/mpeg',
267
+ m4a: 'audio/mp4',
268
+ wav: 'audio/wav',
269
+ ogg: 'audio/ogg',
270
+ flac: 'audio/flac',
271
+ }[ext.toLowerCase()];
272
+ }
273
+ function runYtDlp(args) {
274
+ return new Promise((resolve, reject) => {
275
+ const child = spawn('yt-dlp', args, { shell: false });
276
+ let stdout = '';
277
+ let stderr = '';
278
+ child.stdout.on('data', (data) => {
279
+ stdout += String(data);
280
+ });
281
+ child.stderr.on('data', (data) => {
282
+ stderr += String(data);
283
+ });
284
+ child.on('error', (error) => reject(error.code === 'ENOENT'
285
+ ? new Error('yt-dlp is required for YouTube URLs. Install it from https://github.com/yt-dlp/yt-dlp#installation')
286
+ : error));
287
+ child.on('close', (code) => code === 0
288
+ ? resolve(stdout.trim())
289
+ : reject(new Error(`yt-dlp failed (${code}): ${stderr.trim()}`)));
290
+ });
291
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Video processing pipeline.
3
+ *
4
+ * Uses ffmpeg (via fluent-ffmpeg) to:
5
+ * 1. Extract audio track → send to audio-processor for transcription
6
+ * 2. Extract keyframes at configurable intervals
7
+ * 3. Generate thumbnails for each keyframe
8
+ *
9
+ * The caller (API route) handles Vision LLM captioning of frames.
10
+ */
11
+ export interface VideoProcessResult {
12
+ /** Path to extracted audio file (WAV, 16kHz mono) */
13
+ audioPath?: string;
14
+ /** Extracted keyframe image paths with timestamps */
15
+ frames: {
16
+ path: string;
17
+ timestampSecs: number;
18
+ }[];
19
+ /** Video metadata */
20
+ meta: {
21
+ durationSecs: number;
22
+ width: number;
23
+ height: number;
24
+ codec: string;
25
+ };
26
+ }
27
+ export interface VideoProcessOptions {
28
+ /** Output directory for extracted files */
29
+ outputDir: string;
30
+ /** Extract one frame every N seconds (default: 10) */
31
+ frameIntervalSecs?: number;
32
+ /** Maximum number of frames to extract (default: 100) */
33
+ maxFrames?: number;
34
+ /** Scene detection sensitivity (default: 0.3; lower detects more changes) */
35
+ sceneThreshold?: number;
36
+ /** Maximum number of threads for FFmpeg to use */
37
+ threads?: number;
38
+ /** Whether to run parallel audio and frame extraction */
39
+ parallelExtraction?: boolean;
40
+ /** Skip audio extraction when an authoritative source transcript is available. */
41
+ skipAudioExtraction?: boolean;
42
+ /** Reports actual FFmpeg extraction progress as a value between 0 and 1. */
43
+ onProgress?: (progress: number) => void;
44
+ }
45
+ export interface VideoSamplingPlan {
46
+ /** Normalized video duration used to calculate the plan. */
47
+ durationSecs: number;
48
+ /** Hard ceiling for all extracted frames combined. */
49
+ maxFrames: number;
50
+ /** Cadence used for uniformly distributed coverage frames. */
51
+ periodicIntervalSecs: number;
52
+ /** Maximum number of uniformly distributed coverage frames. */
53
+ periodicFrameCount: number;
54
+ /** Maximum number of additional scene-change frames. */
55
+ sceneFrameCount: number;
56
+ /** Frames reserved for a denser look at the end of the recording. */
57
+ endingFrameCount: number;
58
+ /** Minimum time between selected scene changes. */
59
+ minimumSceneGapSecs: number;
60
+ /** Maximum periodic + scene + ending frames before de-duplication. */
61
+ estimatedFrameCount: number;
62
+ }
63
+ export interface EndingSamplingPlan {
64
+ startSecs: number;
65
+ intervalSecs: number;
66
+ frameCount: number;
67
+ timestamps: number[];
68
+ }
69
+ export interface TimedText {
70
+ text: string;
71
+ startSecs: number;
72
+ endSecs: number;
73
+ }
74
+ export interface MultimodalSegment extends TimedText {
75
+ transcript: string;
76
+ visualContext: string;
77
+ sequence: number;
78
+ }
79
+ /**
80
+ * Fuse speech and visual observations onto one timeline. Each searchable unit
81
+ * contains everything that happened in the same time window instead of an
82
+ * unrelated transcript or frame caption.
83
+ */
84
+ export declare function buildMultimodalSegments(transcript: TimedText[], visuals: TimedText[], durationSecs: number, targetWindowSecs?: number): MultimodalSegment[];
85
+ /**
86
+ * Create a bounded frame-sampling plan without treating maxFrames as a quota.
87
+ *
88
+ * Edited videos receive frequent anchors and scene-change coverage. As duration
89
+ * grows, the anchor cadence widens so multi-hour camera and screen recordings
90
+ * remain searchable without generating hundreds of near-identical frames.
91
+ */
92
+ export declare function createVideoSamplingPlan(durationSecs: number, configuredInterval?: number, maxFrames?: number): VideoSamplingPlan;
93
+ /** Densely sample the ending where final results and decisions commonly appear. */
94
+ export declare function createEndingSamplingPlan(durationSecs: number, maxAdditionalFrames: number): EndingSamplingPlan;
95
+ /**
96
+ * Process a video file: extract audio and keyframes.
97
+ */
98
+ export declare function processVideo(videoPath: string, options: VideoProcessOptions): Promise<VideoProcessResult>;
99
+ /** Extract scene-change frames in one ffmpeg process, with adaptive interval fallback. */
100
+ export declare function extractSceneFrames(videoPath: string, options: {
101
+ outputDir: string;
102
+ maxFrames: number;
103
+ durationSecs?: number;
104
+ intervalSecs?: number;
105
+ sceneThreshold?: number;
106
+ threads?: number;
107
+ onProgress?: (progress: number) => void;
108
+ }): Promise<{
109
+ path: string;
110
+ timestampSecs: number;
111
+ }[]>;
112
+ /**
113
+ * Extract keyframes from a video at regular intervals.
114
+ */
115
+ export declare function extractFrames(videoPath: string, options: {
116
+ outputDir: string;
117
+ intervalSecs: number;
118
+ maxFrames: number;
119
+ durationSecs?: number;
120
+ /** Optional range start, used for denser ending/outcome evidence. */
121
+ startSecs?: number;
122
+ threads?: number;
123
+ onProgress?: (progress: number) => void;
124
+ }): Promise<{
125
+ path: string;
126
+ timestampSecs: number;
127
+ }[]>;