@sylphx/video-reader-mcp 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -43,6 +43,8 @@ Cue is **local-first timeline architecture**: streams, dialogue, scene cuts, **s
43
43
 
44
44
  Spec: [docs/specs/agent-video-read-contract.md](docs/specs/agent-video-read-contract.md)
45
45
 
46
+ **Local-first frontier:** ffmpeg/ffprobe + structural keyframes, optional local whisper ASR. No cloud required.
47
+
46
48
  ## Product docs
47
49
 
48
50
  | Doc | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/video-reader-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "mcpName": "io.github.SylphxAI/video-reader-mcp",
5
5
  "description": "Cue \u2014 Evidence-first video reading for AI agents \u2014 ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.",
6
6
  "type": "module",
package/src/utils/asr.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
1
5
  import { shouldUseRustAsrEngine, transcribeViaRustEngine } from '../engine/rust-asr.js';
2
6
  import type { TranscriptSegment } from '../types/timeline.js';
3
- import { isBinaryAvailable } from './exec.js';
7
+ import { execBinary, isBinaryAvailable } from './exec.js';
4
8
 
5
9
  const ASR_CANDIDATES = ['whisper-cli', 'whisper-cpp', 'whisper', 'vosk-transcriber'] as const;
6
10
 
@@ -13,6 +17,124 @@ export const detectAsrAdapter = async (): Promise<string | null> => {
13
17
  return null;
14
18
  };
15
19
 
20
+ /** Parse whisper.txt style lines: [00:00.000 --> 00:01.000] text */
21
+ export function parseWhisperTxt(raw: string): TranscriptSegment[] {
22
+ const segments: TranscriptSegment[] = [];
23
+ const re =
24
+ /\[(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\s*-->\s*(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]\s*(.+)/g;
25
+ const toMs = (h: string | undefined, min: string, sec: string, ms: string | undefined) => {
26
+ const hours = h ? Number.parseInt(h.replace(':', ''), 10) : 0;
27
+ const minutes = Number.parseInt(min, 10);
28
+ const seconds = Number.parseInt(sec, 10);
29
+ const millis = ms ? Number.parseInt(ms.padEnd(3, '0').slice(0, 3), 10) : 0;
30
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis;
31
+ };
32
+ let m = re.exec(raw);
33
+ while (m !== null) {
34
+ const start = toMs(m[1], m[2] ?? '0', m[3] ?? '0', m[4]);
35
+ const end = toMs(m[5], m[6] ?? '0', m[7] ?? '0', m[8]);
36
+ const text = (m[9] ?? '').trim();
37
+ if (text) {
38
+ segments.push({
39
+ start_ms: start,
40
+ end_ms: end,
41
+ text,
42
+ provenance: { method: 'asr_adapter', adapter: 'whisper-cli' },
43
+ });
44
+ }
45
+ m = re.exec(raw);
46
+ }
47
+ if (segments.length === 0) {
48
+ const lines = raw
49
+ .split(/\r?\n/)
50
+ .map((l) => l.trim())
51
+ .filter((l) => l && !l.startsWith('['));
52
+ lines.forEach((text, i) => {
53
+ segments.push({
54
+ start_ms: i * 1000,
55
+ end_ms: (i + 1) * 1000,
56
+ text,
57
+ provenance: { method: 'asr_adapter', adapter: 'whisper-cli' },
58
+ });
59
+ });
60
+ }
61
+ return segments;
62
+ }
63
+
64
+ async function extractWavMono16k(
65
+ videoPath: string
66
+ ): Promise<{ wavPath: string; dir: string } | { error: string }> {
67
+ const ffmpegOk = await isBinaryAvailable('ffmpeg');
68
+ if (!ffmpegOk) {
69
+ return { error: 'ffmpeg required to extract audio for local whisper ASR' };
70
+ }
71
+ const dir = mkdtempSync(join(tmpdir(), 'cue-asr-'));
72
+ const wavPath = join(dir, 'audio.wav');
73
+ try {
74
+ await execBinary(
75
+ 'ffmpeg',
76
+ ['-hide_banner', '-y', '-i', videoPath, '-ac', '1', '-ar', '16000', '-f', 'wav', wavPath],
77
+ { timeoutMs: 300_000 }
78
+ );
79
+ return { wavPath, dir };
80
+ } catch (error: unknown) {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ return { error: message };
84
+ }
85
+ }
86
+
87
+ /** Local-first frontier ASR: whisper-cli/whisper.cpp on PATH (no cloud). */
88
+ export async function runLocalWhisperTranscript(
89
+ videoPath: string,
90
+ adapter: string
91
+ ): Promise<{ transcript: TranscriptSegment[]; warning?: string }> {
92
+ const wav = await extractWavMono16k(videoPath);
93
+ if ('error' in wav) {
94
+ return { transcript: [], warning: `ASR audio extract failed: ${wav.error}` };
95
+ }
96
+
97
+ const outBase = join(wav.dir, 'out');
98
+ const model = process.env.CUE_WHISPER_MODEL ?? process.env.WHISPER_MODEL ?? '';
99
+ const args: string[] = ['-f', wav.wavPath, '-otxt', '-of', outBase];
100
+ if (model) args.push('-m', model);
101
+
102
+ try {
103
+ const result = spawnSync(adapter, args, {
104
+ encoding: 'utf8',
105
+ timeout: 600_000,
106
+ windowsHide: true,
107
+ maxBuffer: 20 * 1024 * 1024,
108
+ });
109
+ if (result.status !== 0) {
110
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
111
+ return {
112
+ transcript: [],
113
+ warning: `Local ASR (${adapter}) failed: ${stderr || `exit ${String(result.status)}`}`,
114
+ };
115
+ }
116
+ let raw = '';
117
+ try {
118
+ raw = readFileSync(`${outBase}.txt`, 'utf8');
119
+ } catch {
120
+ raw = typeof result.stdout === 'string' ? result.stdout : '';
121
+ }
122
+ const transcript = parseWhisperTxt(raw).map((seg) => ({
123
+ ...seg,
124
+ provenance: { method: 'asr_adapter' as const, adapter },
125
+ }));
126
+ if (transcript.length === 0) {
127
+ return { transcript: [], warning: `Local ASR (${adapter}) produced empty transcript` };
128
+ }
129
+ return { transcript };
130
+ } catch (error: unknown) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ return { transcript: [], warning: `Local ASR (${adapter}) error: ${message}` };
133
+ } finally {
134
+ rmSync(wav.dir, { recursive: true, force: true });
135
+ }
136
+ }
137
+
16
138
  export const tryAsrTranscript = async (
17
139
  videoPath: string,
18
140
  enabled: boolean
@@ -29,18 +151,12 @@ export const tryAsrTranscript = async (
29
151
  ...(response.result.warning ? { warning: response.result.warning } : {}),
30
152
  };
31
153
  }
32
-
33
- if (response.code === 'ADAPTER_UNAVAILABLE') {
154
+ if (response.code !== 'ADAPTER_UNAVAILABLE') {
34
155
  return {
35
156
  transcript: [],
36
- warning: `${response.message} Checked whisper-cli and whisper-cpp.`,
157
+ warning: `ASR transcription failed: ${response.message}`,
37
158
  };
38
159
  }
39
-
40
- return {
41
- transcript: [],
42
- warning: `ASR transcription failed: ${response.message}`,
43
- };
44
160
  }
45
161
 
46
162
  const adapter = await detectAsrAdapter();
@@ -48,12 +164,16 @@ export const tryAsrTranscript = async (
48
164
  return {
49
165
  transcript: [],
50
166
  warning:
51
- 'ASR requested but no local adapter found (checked whisper-cli, whisper-cpp, whisper, vosk-transcriber); transcript skipped.',
167
+ 'ASR requested but no local adapter found (checked whisper-cli, whisper-cpp, whisper, vosk-transcriber). Install whisper.cpp for local-first frontier ASR.',
52
168
  };
53
169
  }
54
170
 
171
+ if (adapter === 'whisper-cli' || adapter === 'whisper-cpp' || adapter === 'whisper') {
172
+ return runLocalWhisperTranscript(videoPath, adapter);
173
+ }
174
+
55
175
  return {
56
176
  transcript: [],
57
- warning: `ASR adapter "${adapter}" detected but Rust ASR engine is not enabled. Build video-reader-cli or set VIDEO_READER_USE_RUST_ASR=1.`,
177
+ warning: `ASR adapter "${adapter}" detected but no local runner implemented for it yet.`,
58
178
  };
59
179
  };