@sylphx/video-reader-mcp 0.1.0 → 0.1.1
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 +222 -41
- package/bin/native/video-reader-mcp-server +0 -0
- package/bin/video-reader-mcp +48 -0
- package/dist/doctor-cli.js +167 -0
- package/package.json +23 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +132 -0
- package/src/engine/rust-asr.ts +98 -0
- package/src/engine/rust-frames.ts +95 -0
- package/src/engine/rust-timeline.ts +164 -0
- package/src/engine/rust-video-evidence.ts +132 -0
- package/src/handlers/readVideo.ts +41 -0
- package/src/handlers/videoEvidence.ts +161 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readVideo.ts +67 -0
- package/src/schemas/videoEvidence.ts +53 -0
- package/src/sdk.ts +44 -0
- package/src/types/timeline.ts +106 -0
- package/src/utils/asr.ts +59 -0
- package/src/utils/errors.ts +16 -0
- package/src/utils/exec.ts +76 -0
- package/src/utils/ffmpeg.ts +81 -0
- package/src/utils/ffprobe.ts +137 -0
- package/src/utils/frameOcr.ts +99 -0
- package/src/utils/frames.ts +93 -0
- package/src/utils/pathUtils.ts +36 -0
- package/src/utils/scenes.ts +63 -0
- package/src/utils/subtitles.ts +101 -0
- package/src/video/readCoordinator.ts +165 -0
- package/dist/handlers/readVideo.js +0 -24
- package/dist/index.js +0 -59
- package/dist/mcp.js +0 -181
- package/dist/schemas/readVideo.js +0 -33
- package/dist/types/timeline.js +0 -1
- package/dist/utils/asr.js +0 -26
- package/dist/utils/errors.js +0 -14
- package/dist/utils/exec.js +0 -56
- package/dist/utils/ffmpeg.js +0 -66
- package/dist/utils/ffprobe.js +0 -79
- package/dist/utils/pathUtils.js +0 -31
- package/dist/utils/scenes.js +0 -49
- package/dist/utils/subtitles.js +0 -85
- package/dist/video/readCoordinator.js +0 -81
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface Provenance {
|
|
2
|
+
source: string;
|
|
3
|
+
tool: 'read_video';
|
|
4
|
+
version: string;
|
|
5
|
+
extracted_at: string;
|
|
6
|
+
source_hash?: string;
|
|
7
|
+
cache_key?: string;
|
|
8
|
+
assembly_route?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StreamInfo {
|
|
12
|
+
index: number;
|
|
13
|
+
codec_type: string;
|
|
14
|
+
codec_name?: string;
|
|
15
|
+
language?: string;
|
|
16
|
+
channels?: number;
|
|
17
|
+
sample_rate?: number;
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
avg_frame_rate?: string;
|
|
21
|
+
r_frame_rate?: string;
|
|
22
|
+
bit_rate?: number;
|
|
23
|
+
disposition?: Record<string, number>;
|
|
24
|
+
tags?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChapterInfo {
|
|
28
|
+
id: number;
|
|
29
|
+
start_ms: number;
|
|
30
|
+
end_ms: number;
|
|
31
|
+
title?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SceneInfo {
|
|
35
|
+
index: number;
|
|
36
|
+
time_ms: number;
|
|
37
|
+
provenance: {
|
|
38
|
+
method: 'ffmpeg_scene_filter';
|
|
39
|
+
threshold: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SubtitleCue {
|
|
44
|
+
index: number;
|
|
45
|
+
start_ms: number;
|
|
46
|
+
end_ms: number;
|
|
47
|
+
text: string;
|
|
48
|
+
stream_index?: number;
|
|
49
|
+
language?: string;
|
|
50
|
+
provenance: {
|
|
51
|
+
method: 'ffmpeg_extract' | 'embedded_parse';
|
|
52
|
+
format: 'srt' | 'vtt' | 'webvtt';
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TranscriptSegment {
|
|
57
|
+
start_ms: number;
|
|
58
|
+
end_ms: number;
|
|
59
|
+
text: string;
|
|
60
|
+
provenance: {
|
|
61
|
+
method: 'asr_adapter';
|
|
62
|
+
adapter: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FrameEvidence {
|
|
67
|
+
index: number;
|
|
68
|
+
time_ms: number;
|
|
69
|
+
provenance: {
|
|
70
|
+
method: 'ffmpeg_keyframe_select';
|
|
71
|
+
pict_type: 'I';
|
|
72
|
+
};
|
|
73
|
+
route?: string;
|
|
74
|
+
frame_hash?: string;
|
|
75
|
+
mime?: string;
|
|
76
|
+
width?: number;
|
|
77
|
+
height?: number;
|
|
78
|
+
image_base64?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FormatInfo {
|
|
82
|
+
format_name?: string;
|
|
83
|
+
duration_ms: number;
|
|
84
|
+
bit_rate?: number;
|
|
85
|
+
size_bytes?: number;
|
|
86
|
+
tags?: Record<string, string>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface TimelineDocument {
|
|
90
|
+
provenance: Provenance;
|
|
91
|
+
format: FormatInfo;
|
|
92
|
+
streams: StreamInfo[];
|
|
93
|
+
chapters: ChapterInfo[];
|
|
94
|
+
scenes: SceneInfo[];
|
|
95
|
+
subtitles: SubtitleCue[];
|
|
96
|
+
transcript: TranscriptSegment[];
|
|
97
|
+
keyframes: FrameEvidence[];
|
|
98
|
+
warnings: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface VideoSourceResult {
|
|
102
|
+
source: string;
|
|
103
|
+
success: boolean;
|
|
104
|
+
data?: TimelineDocument;
|
|
105
|
+
error?: string;
|
|
106
|
+
}
|
package/src/utils/asr.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { shouldUseRustAsrEngine, transcribeViaRustEngine } from '../engine/rust-asr.js';
|
|
2
|
+
import type { TranscriptSegment } from '../types/timeline.js';
|
|
3
|
+
import { isBinaryAvailable } from './exec.js';
|
|
4
|
+
|
|
5
|
+
const ASR_CANDIDATES = ['whisper-cli', 'whisper-cpp', 'whisper', 'vosk-transcriber'] as const;
|
|
6
|
+
|
|
7
|
+
export const detectAsrAdapter = async (): Promise<string | null> => {
|
|
8
|
+
for (const candidate of ASR_CANDIDATES) {
|
|
9
|
+
if (await isBinaryAvailable(candidate)) {
|
|
10
|
+
return candidate;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const tryAsrTranscript = async (
|
|
17
|
+
videoPath: string,
|
|
18
|
+
enabled: boolean
|
|
19
|
+
): Promise<{ transcript: TranscriptSegment[]; warning?: string }> => {
|
|
20
|
+
if (!enabled) {
|
|
21
|
+
return { transcript: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (shouldUseRustAsrEngine()) {
|
|
25
|
+
const response = transcribeViaRustEngine(videoPath);
|
|
26
|
+
if (response.ok) {
|
|
27
|
+
return {
|
|
28
|
+
transcript: response.result.transcript,
|
|
29
|
+
...(response.result.warning ? { warning: response.result.warning } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (response.code === 'ADAPTER_UNAVAILABLE') {
|
|
34
|
+
return {
|
|
35
|
+
transcript: [],
|
|
36
|
+
warning: `${response.message} Checked whisper-cli and whisper-cpp.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
transcript: [],
|
|
42
|
+
warning: `ASR transcription failed: ${response.message}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const adapter = await detectAsrAdapter();
|
|
47
|
+
if (!adapter) {
|
|
48
|
+
return {
|
|
49
|
+
transcript: [],
|
|
50
|
+
warning:
|
|
51
|
+
'ASR requested but no local adapter found (checked whisper-cli, whisper-cpp, whisper, vosk-transcriber); transcript skipped.',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
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.`,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export enum ErrorCode {
|
|
2
|
+
InvalidParams = -32602,
|
|
3
|
+
InvalidRequest = -32600,
|
|
4
|
+
MethodNotFound = -32601,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class VideoError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public code: ErrorCode,
|
|
10
|
+
message: string,
|
|
11
|
+
options?: { cause?: Error | undefined }
|
|
12
|
+
) {
|
|
13
|
+
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
14
|
+
this.name = 'VideoError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface ExecResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ExecOptions {
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
maxBuffer?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
17
|
+
const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
export const execBinary = async (
|
|
20
|
+
binary: string,
|
|
21
|
+
args: readonly string[],
|
|
22
|
+
options: ExecOptions = {}
|
|
23
|
+
): Promise<ExecResult> => {
|
|
24
|
+
const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
25
|
+
const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { stdout, stderr } = await execFileAsync(binary, [...args], {
|
|
29
|
+
timeout,
|
|
30
|
+
maxBuffer,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
stdout: typeof stdout === 'string' ? stdout : String(stdout),
|
|
35
|
+
stderr: typeof stderr === 'string' ? stderr : String(stderr),
|
|
36
|
+
};
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
if (
|
|
39
|
+
typeof error === 'object' &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
'killed' in error &&
|
|
42
|
+
error.killed === true &&
|
|
43
|
+
'signal' in error &&
|
|
44
|
+
error.signal === 'SIGTERM'
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`${binary} timed out after ${timeout}ms`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const binaryCache = new Map<string, boolean>();
|
|
53
|
+
|
|
54
|
+
export const isBinaryAvailable = async (binary: string): Promise<boolean> => {
|
|
55
|
+
const cached = binaryCache.get(binary);
|
|
56
|
+
if (cached !== undefined) return cached;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await execFileAsync(binary, ['-version'], { timeout: 5_000, encoding: 'utf8' });
|
|
60
|
+
binaryCache.set(binary, true);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
try {
|
|
64
|
+
await execFileAsync(binary, ['--version'], { timeout: 5_000, encoding: 'utf8' });
|
|
65
|
+
binaryCache.set(binary, true);
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
binaryCache.set(binary, false);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const clearBinaryCache = (): void => {
|
|
75
|
+
binaryCache.clear();
|
|
76
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { SubtitleCue } from '../types/timeline.js';
|
|
5
|
+
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
6
|
+
import type { FfprobeStream } from './ffprobe.js';
|
|
7
|
+
import { parseSubtitleContent } from './subtitles.js';
|
|
8
|
+
|
|
9
|
+
const subtitleFormatForStream = (stream: FfprobeStream): 'srt' | 'vtt' | 'webvtt' => {
|
|
10
|
+
const codec = stream.codec_name?.toLowerCase() ?? '';
|
|
11
|
+
if (codec.includes('webvtt') || codec === 'vtt') return 'vtt';
|
|
12
|
+
return 'srt';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const extractSubtitles = async (
|
|
16
|
+
videoPath: string,
|
|
17
|
+
subtitleStreams: FfprobeStream[]
|
|
18
|
+
): Promise<{ subtitles: SubtitleCue[]; warnings: string[] }> => {
|
|
19
|
+
const warnings: string[] = [];
|
|
20
|
+
const subtitles: SubtitleCue[] = [];
|
|
21
|
+
|
|
22
|
+
if (subtitleStreams.length === 0) {
|
|
23
|
+
return { subtitles, warnings: ['No embedded subtitle streams found.'] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const available = await isBinaryAvailable('ffmpeg');
|
|
27
|
+
if (!available) {
|
|
28
|
+
return {
|
|
29
|
+
subtitles,
|
|
30
|
+
warnings: ['ffmpeg is not installed; subtitle extraction skipped.'],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-reader-mcp-'));
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
for (const stream of subtitleStreams) {
|
|
38
|
+
const format = subtitleFormatForStream(stream);
|
|
39
|
+
const extension = format === 'srt' ? 'srt' : 'vtt';
|
|
40
|
+
const outputPath = path.join(tempDir, `sub-${stream.index}.${extension}`);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await execBinary(
|
|
44
|
+
'ffmpeg',
|
|
45
|
+
[
|
|
46
|
+
'-hide_banner',
|
|
47
|
+
'-y',
|
|
48
|
+
'-i',
|
|
49
|
+
videoPath,
|
|
50
|
+
'-map',
|
|
51
|
+
`0:${stream.index}`,
|
|
52
|
+
'-c:s',
|
|
53
|
+
format === 'srt' ? 'srt' : 'webvtt',
|
|
54
|
+
outputPath,
|
|
55
|
+
],
|
|
56
|
+
{ timeoutMs: 120_000 }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const content = await readFile(outputPath, 'utf8');
|
|
60
|
+
const cues = parseSubtitleContent(content, format).map((cue) => ({
|
|
61
|
+
...cue,
|
|
62
|
+
index: subtitles.length + cue.index,
|
|
63
|
+
stream_index: stream.index,
|
|
64
|
+
...(stream.tags?.language ? { language: stream.tags.language } : {}),
|
|
65
|
+
provenance: {
|
|
66
|
+
method: 'ffmpeg_extract' as const,
|
|
67
|
+
format,
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
subtitles.push(...cues);
|
|
71
|
+
} catch (error: unknown) {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
warnings.push(`Subtitle stream ${stream.index} extraction failed: ${message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { subtitles, warnings };
|
|
81
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
2
|
+
|
|
3
|
+
export interface FfprobeChapter {
|
|
4
|
+
id: number;
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
tags?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FfprobeStream {
|
|
11
|
+
index: number;
|
|
12
|
+
codec_type: string;
|
|
13
|
+
codec_name?: string;
|
|
14
|
+
tags?: Record<string, string>;
|
|
15
|
+
channels?: number;
|
|
16
|
+
sample_rate?: string;
|
|
17
|
+
width?: number;
|
|
18
|
+
height?: number;
|
|
19
|
+
avg_frame_rate?: string;
|
|
20
|
+
r_frame_rate?: string;
|
|
21
|
+
bit_rate?: string;
|
|
22
|
+
disposition?: Record<string, number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FfprobeFormat {
|
|
26
|
+
format_name?: string;
|
|
27
|
+
duration?: string;
|
|
28
|
+
bit_rate?: string;
|
|
29
|
+
size?: string;
|
|
30
|
+
tags?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FfprobeResult {
|
|
34
|
+
streams: FfprobeStream[];
|
|
35
|
+
format: FfprobeFormat;
|
|
36
|
+
chapters?: FfprobeChapter[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const parseFfprobeJson = (raw: string): FfprobeResult => {
|
|
40
|
+
const parsed = JSON.parse(raw) as Partial<FfprobeResult>;
|
|
41
|
+
return {
|
|
42
|
+
streams: Array.isArray(parsed.streams) ? parsed.streams : [],
|
|
43
|
+
format: parsed.format ?? {},
|
|
44
|
+
chapters: Array.isArray(parsed.chapters) ? parsed.chapters : [],
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const secondsToMs = (value: string | number | undefined): number => {
|
|
49
|
+
if (value === undefined) return 0;
|
|
50
|
+
const seconds = typeof value === 'number' ? value : Number.parseFloat(value);
|
|
51
|
+
if (!Number.isFinite(seconds)) return 0;
|
|
52
|
+
return Math.round(seconds * 1000);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const mapStreams = (streams: FfprobeStream[]) =>
|
|
56
|
+
streams.map((stream) => ({
|
|
57
|
+
index: stream.index,
|
|
58
|
+
codec_type: stream.codec_type,
|
|
59
|
+
...(stream.codec_name ? { codec_name: stream.codec_name } : {}),
|
|
60
|
+
...(stream.tags?.language ? { language: stream.tags.language } : {}),
|
|
61
|
+
...(stream.channels !== undefined ? { channels: stream.channels } : {}),
|
|
62
|
+
...(stream.sample_rate ? { sample_rate: Number.parseInt(stream.sample_rate, 10) } : {}),
|
|
63
|
+
...(stream.width !== undefined ? { width: stream.width } : {}),
|
|
64
|
+
...(stream.height !== undefined ? { height: stream.height } : {}),
|
|
65
|
+
...(stream.avg_frame_rate ? { avg_frame_rate: stream.avg_frame_rate } : {}),
|
|
66
|
+
...(stream.r_frame_rate ? { r_frame_rate: stream.r_frame_rate } : {}),
|
|
67
|
+
...(stream.bit_rate ? { bit_rate: Number.parseInt(stream.bit_rate, 10) } : {}),
|
|
68
|
+
...(stream.disposition ? { disposition: stream.disposition } : {}),
|
|
69
|
+
...(stream.tags ? { tags: stream.tags } : {}),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
export const mapChapters = (chapters: FfprobeChapter[] | undefined) =>
|
|
73
|
+
(chapters ?? []).map((chapter) => ({
|
|
74
|
+
id: chapter.id,
|
|
75
|
+
start_ms: secondsToMs(chapter.start),
|
|
76
|
+
end_ms: secondsToMs(chapter.end),
|
|
77
|
+
...(chapter.tags?.title ? { title: chapter.tags.title } : {}),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
export const collectProbeWarnings = (probe: FfprobeResult, includeStreams: boolean): string[] => {
|
|
81
|
+
const warnings: string[] = [];
|
|
82
|
+
const videoStreams = probe.streams.filter((s) => s.codec_type === 'video');
|
|
83
|
+
const audioStreams = probe.streams.filter((s) => s.codec_type === 'audio');
|
|
84
|
+
|
|
85
|
+
if (includeStreams && videoStreams.length === 0) {
|
|
86
|
+
warnings.push('No video stream detected.');
|
|
87
|
+
}
|
|
88
|
+
if (includeStreams && audioStreams.length === 0) {
|
|
89
|
+
warnings.push('No audio stream detected.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const stream of videoStreams) {
|
|
93
|
+
if (
|
|
94
|
+
stream.avg_frame_rate &&
|
|
95
|
+
stream.r_frame_rate &&
|
|
96
|
+
stream.avg_frame_rate !== stream.r_frame_rate
|
|
97
|
+
) {
|
|
98
|
+
warnings.push(
|
|
99
|
+
`Stream ${stream.index}: variable frame rate suspected (avg_frame_rate=${stream.avg_frame_rate}, r_frame_rate=${stream.r_frame_rate}).`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const duration = probe.format.duration;
|
|
105
|
+
if (!duration || Number.parseFloat(duration) <= 0) {
|
|
106
|
+
warnings.push('Duration unavailable or zero; timeline bounds may be incomplete.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return warnings;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const runFfprobe = async (videoPath: string): Promise<FfprobeResult> => {
|
|
113
|
+
const available = await isBinaryAvailable('ffprobe');
|
|
114
|
+
if (!available) {
|
|
115
|
+
throw new Error('ffprobe is not installed or not on PATH');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { stdout } = await execBinary(
|
|
119
|
+
'ffprobe',
|
|
120
|
+
[
|
|
121
|
+
'-v',
|
|
122
|
+
'quiet',
|
|
123
|
+
'-print_format',
|
|
124
|
+
'json',
|
|
125
|
+
'-show_format',
|
|
126
|
+
'-show_streams',
|
|
127
|
+
'-show_chapters',
|
|
128
|
+
videoPath,
|
|
129
|
+
],
|
|
130
|
+
{ timeoutMs: 60_000 }
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return parseFfprobeJson(stdout);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const findSubtitleStreams = (streams: FfprobeStream[]): FfprobeStream[] =>
|
|
137
|
+
streams.filter((stream) => stream.codec_type === 'subtitle');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type FrameOcrLine = {
|
|
7
|
+
text: string;
|
|
8
|
+
confidence?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type FrameOcrResult = {
|
|
12
|
+
available: boolean;
|
|
13
|
+
route: string;
|
|
14
|
+
skipped_reason?: string;
|
|
15
|
+
languages: string[];
|
|
16
|
+
lines: FrameOcrLine[];
|
|
17
|
+
line_count: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const isTesseractAvailable = (): boolean => {
|
|
21
|
+
const result = spawnSync('tesseract', ['--version'], {
|
|
22
|
+
timeout: 2_500,
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
});
|
|
26
|
+
return result.status === 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** OCR a PNG buffer (base64) with local Tesseract; honest skip when missing. */
|
|
30
|
+
export const ocrPngBase64 = (
|
|
31
|
+
imageBase64: string,
|
|
32
|
+
languages: string[] = ['eng']
|
|
33
|
+
): FrameOcrResult => {
|
|
34
|
+
const langs = languages.length ? languages : ['eng'];
|
|
35
|
+
if (!isTesseractAvailable()) {
|
|
36
|
+
return {
|
|
37
|
+
available: false,
|
|
38
|
+
route: 'tesseract_frame',
|
|
39
|
+
skipped_reason: 'Tesseract is not installed or not available on PATH.',
|
|
40
|
+
languages: langs,
|
|
41
|
+
lines: [],
|
|
42
|
+
line_count: 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), 'cue-ocr-'));
|
|
47
|
+
const pngPath = join(dir, 'frame.png');
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(pngPath, Buffer.from(imageBase64, 'base64'));
|
|
50
|
+
const languageArg = langs.join('+');
|
|
51
|
+
const result = spawnSync('tesseract', [pngPath, 'stdout', '-l', languageArg, '--psm', '6'], {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
timeout: 60_000,
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
if (result.error) {
|
|
58
|
+
return {
|
|
59
|
+
available: false,
|
|
60
|
+
route: 'tesseract_frame',
|
|
61
|
+
skipped_reason: `Tesseract failed to start: ${result.error.message}`,
|
|
62
|
+
languages: langs,
|
|
63
|
+
lines: [],
|
|
64
|
+
line_count: 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
69
|
+
return {
|
|
70
|
+
available: false,
|
|
71
|
+
route: 'tesseract_frame',
|
|
72
|
+
skipped_reason:
|
|
73
|
+
stderr.length > 0 ? stderr : `Tesseract exited with status ${String(result.status)}.`,
|
|
74
|
+
languages: langs,
|
|
75
|
+
lines: [],
|
|
76
|
+
line_count: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const text = typeof result.stdout === 'string' ? result.stdout : '';
|
|
80
|
+
const lines = text
|
|
81
|
+
.split(/\r?\n/)
|
|
82
|
+
.map((line) => line.trim())
|
|
83
|
+
.filter((line) => line.length > 0)
|
|
84
|
+
.map((line) => ({ text: line }));
|
|
85
|
+
return {
|
|
86
|
+
available: true,
|
|
87
|
+
route: 'tesseract_frame',
|
|
88
|
+
languages: langs,
|
|
89
|
+
lines,
|
|
90
|
+
line_count: lines.length,
|
|
91
|
+
};
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore cleanup errors
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { extractKeyframesViaRustEngine, shouldUseRustFramesEngine } from '../engine/rust-frames.js';
|
|
2
|
+
import type { FrameEvidence } from '../types/timeline.js';
|
|
3
|
+
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
4
|
+
|
|
5
|
+
const KEYFRAME_PTS_TIME_RE = /pts_time:([0-9.]+)/g;
|
|
6
|
+
|
|
7
|
+
export const parseKeyframeFilterOutput = (stderr: string): FrameEvidence[] => {
|
|
8
|
+
const keyframes: FrameEvidence[] = [];
|
|
9
|
+
let match: RegExpExecArray | null = KEYFRAME_PTS_TIME_RE.exec(stderr);
|
|
10
|
+
|
|
11
|
+
while (match !== null) {
|
|
12
|
+
const seconds = Number.parseFloat(match[1]);
|
|
13
|
+
if (!Number.isFinite(seconds)) continue;
|
|
14
|
+
|
|
15
|
+
keyframes.push({
|
|
16
|
+
index: keyframes.length,
|
|
17
|
+
time_ms: Math.round(seconds * 1000),
|
|
18
|
+
provenance: {
|
|
19
|
+
method: 'ffmpeg_keyframe_select',
|
|
20
|
+
pict_type: 'I',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
match = KEYFRAME_PTS_TIME_RE.exec(stderr);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return keyframes;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const extractKeyframes = async (
|
|
30
|
+
videoPath: string,
|
|
31
|
+
limit = 8,
|
|
32
|
+
options: {
|
|
33
|
+
includeImages?: boolean | undefined;
|
|
34
|
+
maxDimension?: number | undefined;
|
|
35
|
+
} = {}
|
|
36
|
+
): Promise<{ keyframes: FrameEvidence[]; warning?: string }> => {
|
|
37
|
+
const boundedLimit = Math.max(1, Math.min(limit, 64));
|
|
38
|
+
const includeImages = options.includeImages ?? false;
|
|
39
|
+
|
|
40
|
+
if (shouldUseRustFramesEngine()) {
|
|
41
|
+
const response = extractKeyframesViaRustEngine({
|
|
42
|
+
videoPath,
|
|
43
|
+
limit: boundedLimit,
|
|
44
|
+
includeImages,
|
|
45
|
+
...(options.maxDimension !== undefined ? { maxDimension: options.maxDimension } : {}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (response.ok) {
|
|
49
|
+
return { keyframes: response.keyframes };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
keyframes: [],
|
|
54
|
+
warning: `Keyframe extraction failed: ${response.message}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const available = await isBinaryAvailable('ffmpeg');
|
|
59
|
+
if (!available) {
|
|
60
|
+
return {
|
|
61
|
+
keyframes: [],
|
|
62
|
+
warning: 'ffmpeg is not installed; keyframe index extraction skipped.',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const { stderr } = await execBinary(
|
|
68
|
+
'ffmpeg',
|
|
69
|
+
[
|
|
70
|
+
'-hide_banner',
|
|
71
|
+
'-i',
|
|
72
|
+
videoPath,
|
|
73
|
+
'-vf',
|
|
74
|
+
"select='eq(pict_type,I)',showinfo",
|
|
75
|
+
'-vsync',
|
|
76
|
+
'vfr',
|
|
77
|
+
'-f',
|
|
78
|
+
'null',
|
|
79
|
+
'-',
|
|
80
|
+
],
|
|
81
|
+
{ timeoutMs: 300_000 }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const keyframes = parseKeyframeFilterOutput(stderr).slice(0, boundedLimit);
|
|
85
|
+
return { keyframes };
|
|
86
|
+
} catch (error: unknown) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
return {
|
|
89
|
+
keyframes: [],
|
|
90
|
+
warning: `Keyframe extraction failed: ${message}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ErrorCode, VideoError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const PROJECT_ROOT = process.cwd();
|
|
6
|
+
|
|
7
|
+
const canonicalize = (p: string): string => {
|
|
8
|
+
try {
|
|
9
|
+
return fs.realpathSync(p);
|
|
10
|
+
} catch (err: unknown) {
|
|
11
|
+
if (
|
|
12
|
+
typeof err === 'object' &&
|
|
13
|
+
err !== null &&
|
|
14
|
+
'code' in err &&
|
|
15
|
+
(err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
|
16
|
+
) {
|
|
17
|
+
const parent = path.dirname(p);
|
|
18
|
+
if (parent === p) return p;
|
|
19
|
+
return path.join(canonicalize(parent), path.basename(p));
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const resolvePath = (userPath: string): string => {
|
|
26
|
+
if (typeof userPath !== 'string') {
|
|
27
|
+
throw new VideoError(ErrorCode.InvalidParams, 'Path must be a string.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const normalizedUserPath = path.normalize(userPath);
|
|
31
|
+
const resolved = path.isAbsolute(normalizedUserPath)
|
|
32
|
+
? normalizedUserPath
|
|
33
|
+
: path.resolve(PROJECT_ROOT, normalizedUserPath);
|
|
34
|
+
|
|
35
|
+
return canonicalize(resolved);
|
|
36
|
+
};
|