@sylphx/video-reader-mcp 0.1.2 → 0.1.5

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 |
@@ -1,48 +1,119 @@
1
1
  #!/usr/bin/env bash
2
- # Video Reader MCP launcher — Rust rmcp only (fail-closed, no TS stdio fallback).
2
+ # Cue MCP launcher — Rust rmcp only (fail-closed).
3
+ # Resolves native via optionalDependencies, staged bin/native, or cargo target.
3
4
  set -euo pipefail
4
5
 
5
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+
8
+ host_platform_key() {
9
+ local os arch
10
+ os="$(uname -s 2>/dev/null || echo unknown)"
11
+ arch="$(uname -m 2>/dev/null || echo unknown)"
12
+ case "$os" in
13
+ Darwin) os="darwin" ;;
14
+ Linux) os="linux" ;;
15
+ *) os="$(printf '%s' "$os" | tr '[:upper:]' '[:lower:]')" ;;
16
+ esac
17
+ case "$arch" in
18
+ x86_64|amd64) arch="x64" ;;
19
+ aarch64|arm64) arch="arm64" ;;
20
+ esac
21
+ printf '%s-%s\n' "$os" "$arch"
22
+ }
23
+
24
+ platform_package_name() {
25
+ case "$(host_platform_key)" in
26
+ darwin-arm64) printf '%s\n' '@sylphx/video-reader-mcp-darwin-arm64' ;;
27
+ darwin-x64) printf '%s\n' '@sylphx/video-reader-mcp-darwin-x64' ;;
28
+ linux-x64) printf '%s\n' '@sylphx/video-reader-mcp-linux-x64-gnu' ;;
29
+ linux-arm64) printf '%s\n' '@sylphx/video-reader-mcp-linux-arm64-gnu' ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ is_runnable_native() {
35
+ local candidate="$1"
36
+ [[ -n "$candidate" && -f "$candidate" && -x "$candidate" ]] || return 1
37
+ return 0
38
+ }
39
+
40
+ resolve_from_optional_dep() {
41
+ local pkg_name candidate
42
+ pkg_name="$(platform_package_name 2>/dev/null || true)"
43
+ [[ -n "$pkg_name" ]] || return 1
44
+ local search_roots=(
45
+ "$PACKAGE_ROOT/node_modules"
46
+ "$PACKAGE_ROOT/../node_modules"
47
+ "$PACKAGE_ROOT/../../node_modules"
48
+ )
49
+ for root in "${search_roots[@]}"; do
50
+ candidate="$root/$pkg_name/video-reader-mcp-server"
51
+ if is_runnable_native "$candidate"; then
52
+ printf '%s\n' "$candidate"
53
+ return 0
54
+ fi
55
+ done
56
+ if command -v node >/dev/null 2>&1; then
57
+ candidate="$(
58
+ node -e "
59
+ try {
60
+ const p = require('node:path');
61
+ const { createRequire } = require('node:module');
62
+ const r = createRequire(p.join(process.argv[1], 'package.json'));
63
+ const pkg = r.resolve(process.argv[2] + '/package.json');
64
+ process.stdout.write(p.join(p.dirname(pkg), 'video-reader-mcp-server'));
65
+ } catch { process.exit(1); }
66
+ " "$PACKAGE_ROOT" "$pkg_name" 2>/dev/null || true
67
+ )"
68
+ if is_runnable_native "${candidate:-}"; then
69
+ printf '%s\n' "$candidate"
70
+ return 0
71
+ fi
72
+ fi
73
+ return 1
74
+ }
6
75
 
7
76
  resolve_rust_bin() {
8
- if [[ -n "${VIDEO_READER_MCP_RUST_BIN:-}" && -x "${VIDEO_READER_MCP_RUST_BIN}" ]]; then
9
- printf '%s\n' "${VIDEO_READER_MCP_RUST_BIN}"
10
- return 0
11
- fi
12
-
13
- for candidate in \
14
- "$ROOT/bin/native/video-reader-mcp-server" \
15
- "$ROOT/target/release/video-reader-mcp-server" \
16
- "$ROOT/target/debug/video-reader-mcp-server"; do
17
- if [[ -x "$candidate" ]]; then
18
- printf '%s\n' "$candidate"
19
- return 0
20
- fi
21
- done
22
-
23
- return 1
77
+ if [[ -n "${VIDEO_READER_MCP_RUST_BIN:-}" && -x "${VIDEO_READER_MCP_RUST_BIN}" ]]; then
78
+ printf '%s\n' "${VIDEO_READER_MCP_RUST_BIN}"
79
+ return 0
80
+ fi
81
+ if bin="$(resolve_from_optional_dep)"; then
82
+ printf '%s\n' "$bin"
83
+ return 0
84
+ fi
85
+ for candidate in \
86
+ "$PACKAGE_ROOT/bin/native/video-reader-mcp-server" \
87
+ "$PACKAGE_ROOT/target/release/video-reader-mcp-server" \
88
+ "$PACKAGE_ROOT/target/debug/video-reader-mcp-server"; do
89
+ if is_runnable_native "$candidate"; then
90
+ printf '%s\n' "$candidate"
91
+ return 0
92
+ fi
93
+ done
94
+ return 1
24
95
  }
25
96
 
26
97
  resolve_transport() {
27
- if [[ -n "${VIDEO_READER_MCP_TRANSPORT:-}" ]]; then
28
- printf '%s\n' "${VIDEO_READER_MCP_TRANSPORT}"
29
- return 0
30
- fi
31
- if [[ -n "${MCP_TRANSPORT:-}" ]]; then
32
- printf '%s\n' "${MCP_TRANSPORT}"
33
- return 0
34
- fi
35
- printf '%s\n' "stdio"
98
+ if [[ -n "${VIDEO_READER_MCP_TRANSPORT:-}" ]]; then
99
+ printf '%s\n' "${VIDEO_READER_MCP_TRANSPORT}"
100
+ return 0
101
+ fi
102
+ if [[ -n "${MCP_TRANSPORT:-}" ]]; then
103
+ printf '%s\n' "${MCP_TRANSPORT}"
104
+ return 0
105
+ fi
106
+ printf '%s\n' "stdio"
36
107
  }
37
108
 
38
109
  if bin="$(resolve_rust_bin)"; then
39
- transport="$(resolve_transport)"
40
- if [[ "$transport" == "http" ]]; then
41
- export MCP_TRANSPORT=http
42
- export VIDEO_READER_MCP_TRANSPORT=http
43
- fi
44
- exec "$bin" "$@"
110
+ transport="$(resolve_transport)"
111
+ if [[ "$transport" == "http" ]]; then
112
+ export MCP_TRANSPORT=http
113
+ export VIDEO_READER_MCP_TRANSPORT=http
114
+ fi
115
+ exec "$bin" "$@"
45
116
  fi
46
117
 
47
- echo "[video-reader-mcp] Rust MCP server (rmcp) is not built. Run: bun run build:rust" >&2
118
+ echo "[cue] Rust MCP server not found. Install matching optional native package or run: bun run build:rust" >&2
48
119
  exit 1
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@sylphx/video-reader-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "mcpName": "io.github.SylphxAI/video-reader-mcp",
5
- "description": "Cue \u2014 Evidence-first video reading for AI agents \u2014 ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.",
5
+ "description": "Cue Evidence-first video reading for AI agents ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.",
6
6
  "type": "module",
7
7
  "publishConfig": {
8
8
  "access": "public"
@@ -13,7 +13,7 @@
13
13
  "cue": "./bin/video-reader-mcp"
14
14
  },
15
15
  "files": [
16
- "bin/",
16
+ "bin/video-reader-mcp",
17
17
  "dist/",
18
18
  "README.md",
19
19
  "LICENSE",
@@ -76,5 +76,11 @@
76
76
  "typescript": "^7.0.2"
77
77
  },
78
78
  "packageManager": "bun@1.3.12",
79
- "private": false
79
+ "private": false,
80
+ "optionalDependencies": {
81
+ "@sylphx/video-reader-mcp-darwin-arm64": "0.1.5",
82
+ "@sylphx/video-reader-mcp-darwin-x64": "0.1.5",
83
+ "@sylphx/video-reader-mcp-linux-x64-gnu": "0.1.5",
84
+ "@sylphx/video-reader-mcp-linux-arm64-gnu": "0.1.5"
85
+ }
80
86
  }
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
  };
Binary file