@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,25 @@
1
+ /**
2
+ * Ambient type declaration for the optional `nodejs-whisper` module.
3
+ *
4
+ * This dependency is NOT installed by default — it's only needed when
5
+ * the user selects "Local Whisper" as the transcription provider.
6
+ * The dynamic import in audio-processor.ts catches the missing module
7
+ * at runtime and provides a helpful error message.
8
+ */
9
+ declare module 'nodejs-whisper' {
10
+ interface WhisperOptions {
11
+ modelName?: string;
12
+ autoDownloadModelName?: string;
13
+ whisperOptions?: {
14
+ outputInText?: boolean;
15
+ outputInVtt?: boolean;
16
+ outputInSrt?: boolean;
17
+ outputInCsv?: boolean;
18
+ translateToEnglish?: boolean;
19
+ wordTimestamps?: boolean;
20
+ language?: string;
21
+ };
22
+ }
23
+
24
+ export function nodewhisper(audioPath: string, options: WhisperOptions): Promise<string>;
25
+ }
@@ -0,0 +1,371 @@
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
+
10
+ import type { TranscriptionResult, TranscriptChunk } from './audio-processor.js';
11
+
12
+ export type MediaType = 'audio' | 'video' | 'unknown';
13
+ export interface ImportedMedia {
14
+ path: string;
15
+ originalUrl: string;
16
+ title: string;
17
+ mimeType?: string;
18
+ mediaType: MediaType;
19
+ /** Timestamped first-party captions when the source provides them. */
20
+ sourceTranscript?: TranscriptionResult;
21
+ }
22
+ export interface UrlImportOptions {
23
+ outputDir: string;
24
+ /** Direct-download byte limit (default: 500 MiB). */
25
+ maxBytes?: number;
26
+ /** Maximum YouTube playlist entries (default: 10). */
27
+ playlistMax?: number;
28
+ }
29
+ export interface UrlInspection {
30
+ originalUrl: string;
31
+ title?: string;
32
+ mimeType?: string;
33
+ mediaType: MediaType;
34
+ contentLength?: number;
35
+ durationSecs?: number;
36
+ entryCount?: number;
37
+ isYouTube: boolean;
38
+ }
39
+
40
+ export async function inspectMediaUrl(url: string): Promise<UrlInspection> {
41
+ const parsed = validHttpUrl(url);
42
+ if (isYouTube(parsed)) {
43
+ const output = await runYtDlp([
44
+ '--dump-single-json',
45
+ '--simulate',
46
+ '--flat-playlist',
47
+ '--playlist-end',
48
+ '10',
49
+ url,
50
+ ]);
51
+ const data = JSON.parse(output) as {
52
+ title?: string;
53
+ duration?: number;
54
+ entries?: { duration?: number }[];
55
+ };
56
+ const entries = data.entries?.slice(0, 10) ?? [];
57
+ return {
58
+ originalUrl: url,
59
+ title: data.title,
60
+ durationSecs: data.duration ?? entries.reduce((sum, entry) => sum + (entry.duration ?? 0), 0),
61
+ entryCount: Math.max(entries.length, 1),
62
+ mediaType: 'video',
63
+ isYouTube: true,
64
+ };
65
+ }
66
+ let response = await fetchPublic(url, { method: 'HEAD' });
67
+ if (response.status === 405 || response.status === 501) {
68
+ response = await fetchPublic(url, { headers: { Range: 'bytes=0-0' } });
69
+ }
70
+ if (!response.ok) throw new Error(`Unable to inspect media URL (${response.status})`);
71
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
72
+ const mimeType =
73
+ mediaTypeFromMime(headerMime) === 'unknown'
74
+ ? mimeFromExtension(path.extname(parsed.pathname).slice(1))
75
+ : headerMime;
76
+ const rangeTotal = response.headers.get('content-range')?.match(/\/(\d+)$/)?.[1];
77
+ const inspection = {
78
+ originalUrl: url,
79
+ mimeType,
80
+ mediaType: mediaTypeFromMime(mimeType),
81
+ contentLength: Number(rangeTotal ?? response.headers.get('content-length')) || undefined,
82
+ durationSecs: Number(response.headers.get('content-duration')) || undefined,
83
+ entryCount: 1,
84
+ isYouTube: false,
85
+ };
86
+ await response.body?.cancel();
87
+ return inspection;
88
+ }
89
+
90
+ export async function importMediaUrl(
91
+ url: string,
92
+ options: UrlImportOptions,
93
+ ): Promise<ImportedMedia[]> {
94
+ const parsed = validHttpUrl(url);
95
+ await fs.mkdir(options.outputDir, { recursive: true });
96
+ if (isYouTube(parsed)) {
97
+ const template = path.join(options.outputDir, '%(title).120B [%(id)s].%(ext)s');
98
+ const print =
99
+ '{"path":%(filepath)j,"title":%(title)j,"originalUrl":%(webpage_url)j,"ext":%(ext)j}';
100
+ const output = await runYtDlp([
101
+ '--no-progress',
102
+ '--playlist-end',
103
+ String(options.playlistMax ?? 10),
104
+ '--format',
105
+ 'bestvideo[height<=480]+bestaudio/best[height<=480]/best',
106
+ '--merge-output-format',
107
+ 'mp4',
108
+ '-o',
109
+ template,
110
+ '--print',
111
+ `after_move:${print}`,
112
+ url,
113
+ ]);
114
+ const imported = output
115
+ .split('\n')
116
+ .filter(Boolean)
117
+ .map((line) => {
118
+ const item = JSON.parse(line) as {
119
+ path: string;
120
+ title: string;
121
+ originalUrl: string;
122
+ ext: string;
123
+ };
124
+ return {
125
+ path: item.path,
126
+ title: item.title,
127
+ originalUrl: item.originalUrl || url,
128
+ mimeType: mimeFromExtension(item.ext),
129
+ mediaType: mediaTypeFromMime(mimeFromExtension(item.ext)),
130
+ };
131
+ });
132
+ return Promise.all(
133
+ imported.map(async (item) => ({
134
+ ...item,
135
+ sourceTranscript: await fetchYouTubeTranscript(item.originalUrl).catch(() => undefined),
136
+ })),
137
+ );
138
+ }
139
+ const response = await fetchPublic(url);
140
+ if (!response.ok || !response.body) throw new Error(`Media download failed (${response.status})`);
141
+ const maxBytes = options.maxBytes ?? 500 * 1024 * 1024;
142
+ const declared = Number(response.headers.get('content-length'));
143
+ if (declared > maxBytes) throw new Error(`Media exceeds download limit of ${maxBytes} bytes`);
144
+ const headerMime = response.headers.get('content-type')?.split(';')[0];
145
+ const rawName = path.basename(new URL(response.url).pathname) || 'download';
146
+ const name = rawName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 180) || 'download';
147
+ const mimeType =
148
+ mediaTypeFromMime(headerMime) === 'unknown'
149
+ ? mimeFromExtension(path.extname(name).slice(1))
150
+ : headerMime;
151
+ const outputPath = path.join(options.outputDir, `${randomUUID()}-${name}`);
152
+ let bytes = 0;
153
+ const body = Readable.fromWeb(response.body as never);
154
+ body.on('data', (chunk: Buffer) => {
155
+ bytes += chunk.length;
156
+ if (bytes > maxBytes)
157
+ body.destroy(new Error(`Media exceeds download limit of ${maxBytes} bytes`));
158
+ });
159
+ try {
160
+ await pipeline(body, createWriteStream(outputPath, { flags: 'wx' }));
161
+ } catch (error) {
162
+ await fs.rm(outputPath, { force: true });
163
+ throw error;
164
+ }
165
+ return [
166
+ {
167
+ path: outputPath,
168
+ originalUrl: url,
169
+ title: name,
170
+ mimeType,
171
+ mediaType: mediaTypeFromMime(mimeType),
172
+ },
173
+ ];
174
+ }
175
+
176
+ /**
177
+ * Read the video's own manual or automatic captions through yt-dlp. This is
178
+ * both faster and usually more accurate than transcribing compressed audio,
179
+ * especially for names and non-English speech.
180
+ */
181
+ async function fetchYouTubeTranscript(url: string): Promise<TranscriptionResult | undefined> {
182
+ const output = await runYtDlp(['--dump-single-json', '--skip-download', '--no-playlist', url]);
183
+ const data = JSON.parse(output) as {
184
+ duration?: number;
185
+ language?: string;
186
+ subtitles?: Record<string, SubtitleFormat[]>;
187
+ automatic_captions?: Record<string, SubtitleFormat[]>;
188
+ };
189
+ const manualTrack = selectSubtitleTrack(data.subtitles, data.language);
190
+ const selected = manualTrack ?? selectSubtitleTrack(data.automatic_captions, data.language);
191
+ if (!selected) return undefined;
192
+
193
+ const response = await fetch(selected.url);
194
+ if (!response.ok) throw new Error(`YouTube captions download failed (${response.status})`);
195
+ const transcript = parseYouTubeJson3Transcript(await response.json(), data.duration ?? 0);
196
+ return transcript.chunks.length > 0
197
+ ? {
198
+ ...transcript,
199
+ language: selected.language,
200
+ origin: {
201
+ kind: manualTrack ? 'youtube-manual' : 'youtube-auto',
202
+ language: selected.language,
203
+ },
204
+ }
205
+ : undefined;
206
+ }
207
+
208
+ interface SubtitleFormat {
209
+ ext?: string;
210
+ url?: string;
211
+ }
212
+
213
+ function selectSubtitleTrack(
214
+ tracks: Record<string, SubtitleFormat[]> | undefined,
215
+ language: string | undefined,
216
+ ): { url: string; language: string } | undefined {
217
+ if (!tracks) return undefined;
218
+ const languages = Object.keys(tracks).filter((key) => key !== 'live_chat');
219
+ const baseLanguage = language?.split('-')[0];
220
+ const preferredLanguages = [
221
+ language,
222
+ language ? `${language}-orig` : undefined,
223
+ baseLanguage,
224
+ baseLanguage ? `${baseLanguage}-orig` : undefined,
225
+ ...languages.filter((key) => key.endsWith('-orig')),
226
+ ...languages,
227
+ ].filter((key): key is string => Boolean(key));
228
+
229
+ for (const key of [...new Set(preferredLanguages)]) {
230
+ const format = tracks[key]?.find((candidate) => candidate.ext === 'json3' && candidate.url);
231
+ if (format?.url) return { url: format.url, language: key.replace(/-orig$/, '') };
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ /** Parse YouTube's timestamped json3 caption format into indexing chunks. */
237
+ export function parseYouTubeJson3Transcript(
238
+ data: {
239
+ events?: { tStartMs?: number; dDurationMs?: number; segs?: { utf8?: string }[] }[];
240
+ },
241
+ durationSecs = 0,
242
+ chunkDurationSecs = 30,
243
+ ): TranscriptionResult {
244
+ const cues = (data.events ?? [])
245
+ .map((event) => ({
246
+ text: (event.segs ?? [])
247
+ .map((segment) => segment.utf8 ?? '')
248
+ .join('')
249
+ .replace(/\s+/g, ' ')
250
+ .trim(),
251
+ startSecs: Math.max(0, Number(event.tStartMs ?? 0) / 1_000),
252
+ endSecs: Math.max(
253
+ 0,
254
+ Number(event.tStartMs ?? 0) / 1_000 + Number(event.dDurationMs ?? 0) / 1_000,
255
+ ),
256
+ }))
257
+ .filter((cue) => cue.text);
258
+ const chunks: TranscriptChunk[] = [];
259
+
260
+ for (const cue of cues) {
261
+ const current = chunks.at(-1);
262
+ if (!current || cue.startSecs - current.startSecs >= chunkDurationSecs) {
263
+ chunks.push({ ...cue });
264
+ } else {
265
+ current.text = `${current.text} ${cue.text}`;
266
+ current.endSecs = Math.max(current.endSecs, cue.endSecs);
267
+ }
268
+ }
269
+
270
+ return {
271
+ fullText: chunks.map((chunk) => chunk.text).join(' '),
272
+ chunks,
273
+ durationSecs: Math.max(durationSecs, chunks.at(-1)?.endSecs ?? 0),
274
+ };
275
+ }
276
+
277
+ function validHttpUrl(value: string): URL {
278
+ const url = new URL(value);
279
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
280
+ throw new Error('Only http(s) media URLs are supported');
281
+ return url;
282
+ }
283
+
284
+ async function fetchPublic(url: string, init: RequestInit = {}): Promise<Response> {
285
+ let current = validHttpUrl(url);
286
+ for (let redirects = 0; redirects <= 5; redirects++) {
287
+ await assertPublicHost(current.hostname);
288
+ const response = await fetch(current, { ...init, redirect: 'manual' });
289
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
290
+ const location = response.headers.get('location');
291
+ if (!location) throw new Error('Media URL redirected without a location');
292
+ current = validHttpUrl(new URL(location, current).toString());
293
+ }
294
+ throw new Error('Media URL redirected too many times');
295
+ }
296
+
297
+ async function assertPublicHost(hostname: string): Promise<void> {
298
+ const addresses = isIP(hostname)
299
+ ? [{ address: hostname }]
300
+ : await lookup(hostname, { all: true, verbatim: true });
301
+ if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
302
+ throw new Error('Private or local media URLs are not supported');
303
+ }
304
+ }
305
+
306
+ function isPrivateAddress(address: string): boolean {
307
+ const normalized = address.toLowerCase();
308
+ if (normalized === '::1' || normalized === '::' || normalized.startsWith('fe80:')) return true;
309
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
310
+ const ipv4 = normalized.startsWith('::ffff:') ? normalized.slice(7) : normalized;
311
+ const parts = ipv4.split('.').map(Number);
312
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return false;
313
+ const [a, b] = parts;
314
+ return (
315
+ a === 0 ||
316
+ a === 10 ||
317
+ a === 127 ||
318
+ (a === 169 && b === 254) ||
319
+ (a === 172 && b >= 16 && b <= 31) ||
320
+ (a === 192 && b === 168) ||
321
+ a >= 224
322
+ );
323
+ }
324
+ function isYouTube(url: URL): boolean {
325
+ return /(^|\.)youtube\.com$|(^|\.)youtu\.be$/.test(url.hostname);
326
+ }
327
+ function mediaTypeFromMime(mime?: string): MediaType {
328
+ return mime?.startsWith('video/') ? 'video' : mime?.startsWith('audio/') ? 'audio' : 'unknown';
329
+ }
330
+ function mimeFromExtension(ext: string): string | undefined {
331
+ return (
332
+ {
333
+ mp4: 'video/mp4',
334
+ webm: 'video/webm',
335
+ mkv: 'video/x-matroska',
336
+ mov: 'video/quicktime',
337
+ mp3: 'audio/mpeg',
338
+ m4a: 'audio/mp4',
339
+ wav: 'audio/wav',
340
+ ogg: 'audio/ogg',
341
+ flac: 'audio/flac',
342
+ } as Record<string, string>
343
+ )[ext.toLowerCase()];
344
+ }
345
+ function runYtDlp(args: string[]): Promise<string> {
346
+ return new Promise((resolve, reject) => {
347
+ const child = spawn('yt-dlp', args, { shell: false });
348
+ let stdout = '';
349
+ let stderr = '';
350
+ child.stdout.on('data', (data) => {
351
+ stdout += String(data);
352
+ });
353
+ child.stderr.on('data', (data) => {
354
+ stderr += String(data);
355
+ });
356
+ (child as any).on('error', (error: NodeJS.ErrnoException) =>
357
+ reject(
358
+ error.code === 'ENOENT'
359
+ ? new Error(
360
+ 'yt-dlp is required for YouTube URLs. Install it from https://github.com/yt-dlp/yt-dlp#installation',
361
+ )
362
+ : error,
363
+ ),
364
+ );
365
+ (child as any).on('close', (code: number | null) =>
366
+ code === 0
367
+ ? resolve(stdout.trim())
368
+ : reject(new Error(`yt-dlp failed (${code}): ${stderr.trim()}`)),
369
+ );
370
+ });
371
+ }