@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 +2 -0
- package/package.json +1 -1
- package/src/utils/asr.ts +131 -11
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.
|
|
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:
|
|
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)
|
|
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
|
|
177
|
+
warning: `ASR adapter "${adapter}" detected but no local runner implemented for it yet.`,
|
|
58
178
|
};
|
|
59
179
|
};
|