@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
package/dist/utils/exec.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
5
|
-
const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
6
|
-
export const execBinary = async (binary, args, options = {}) => {
|
|
7
|
-
const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
8
|
-
const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
9
|
-
try {
|
|
10
|
-
const { stdout, stderr } = await execFileAsync(binary, [...args], {
|
|
11
|
-
timeout,
|
|
12
|
-
maxBuffer,
|
|
13
|
-
encoding: 'utf8',
|
|
14
|
-
});
|
|
15
|
-
return {
|
|
16
|
-
stdout: typeof stdout === 'string' ? stdout : String(stdout),
|
|
17
|
-
stderr: typeof stderr === 'string' ? stderr : String(stderr),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
catch (error) {
|
|
21
|
-
if (typeof error === 'object' &&
|
|
22
|
-
error !== null &&
|
|
23
|
-
'killed' in error &&
|
|
24
|
-
error.killed === true &&
|
|
25
|
-
'signal' in error &&
|
|
26
|
-
error.signal === 'SIGTERM') {
|
|
27
|
-
throw new Error(`${binary} timed out after ${timeout}ms`);
|
|
28
|
-
}
|
|
29
|
-
throw error;
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
const binaryCache = new Map();
|
|
33
|
-
export const isBinaryAvailable = async (binary) => {
|
|
34
|
-
const cached = binaryCache.get(binary);
|
|
35
|
-
if (cached !== undefined)
|
|
36
|
-
return cached;
|
|
37
|
-
try {
|
|
38
|
-
await execFileAsync(binary, ['-version'], { timeout: 5_000, encoding: 'utf8' });
|
|
39
|
-
binaryCache.set(binary, true);
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
try {
|
|
44
|
-
await execFileAsync(binary, ['--version'], { timeout: 5_000, encoding: 'utf8' });
|
|
45
|
-
binaryCache.set(binary, true);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
binaryCache.set(binary, false);
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
export const clearBinaryCache = () => {
|
|
55
|
-
binaryCache.clear();
|
|
56
|
-
};
|
package/dist/utils/ffmpeg.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
5
|
-
import { parseSubtitleContent } from './subtitles.js';
|
|
6
|
-
const subtitleFormatForStream = (stream) => {
|
|
7
|
-
const codec = stream.codec_name?.toLowerCase() ?? '';
|
|
8
|
-
if (codec.includes('webvtt') || codec === 'vtt')
|
|
9
|
-
return 'vtt';
|
|
10
|
-
return 'srt';
|
|
11
|
-
};
|
|
12
|
-
export const extractSubtitles = async (videoPath, subtitleStreams) => {
|
|
13
|
-
const warnings = [];
|
|
14
|
-
const subtitles = [];
|
|
15
|
-
if (subtitleStreams.length === 0) {
|
|
16
|
-
return { subtitles, warnings: ['No embedded subtitle streams found.'] };
|
|
17
|
-
}
|
|
18
|
-
const available = await isBinaryAvailable('ffmpeg');
|
|
19
|
-
if (!available) {
|
|
20
|
-
return {
|
|
21
|
-
subtitles,
|
|
22
|
-
warnings: ['ffmpeg is not installed; subtitle extraction skipped.'],
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'video-reader-mcp-'));
|
|
26
|
-
try {
|
|
27
|
-
for (const stream of subtitleStreams) {
|
|
28
|
-
const format = subtitleFormatForStream(stream);
|
|
29
|
-
const extension = format === 'srt' ? 'srt' : 'vtt';
|
|
30
|
-
const outputPath = path.join(tempDir, `sub-${stream.index}.${extension}`);
|
|
31
|
-
try {
|
|
32
|
-
await execBinary('ffmpeg', [
|
|
33
|
-
'-hide_banner',
|
|
34
|
-
'-y',
|
|
35
|
-
'-i',
|
|
36
|
-
videoPath,
|
|
37
|
-
'-map',
|
|
38
|
-
`0:${stream.index}`,
|
|
39
|
-
'-c:s',
|
|
40
|
-
format === 'srt' ? 'srt' : 'webvtt',
|
|
41
|
-
outputPath,
|
|
42
|
-
], { timeoutMs: 120_000 });
|
|
43
|
-
const content = await readFile(outputPath, 'utf8');
|
|
44
|
-
const cues = parseSubtitleContent(content, format).map((cue) => ({
|
|
45
|
-
...cue,
|
|
46
|
-
index: subtitles.length + cue.index,
|
|
47
|
-
stream_index: stream.index,
|
|
48
|
-
...(stream.tags?.language ? { language: stream.tags.language } : {}),
|
|
49
|
-
provenance: {
|
|
50
|
-
method: 'ffmpeg_extract',
|
|
51
|
-
format,
|
|
52
|
-
},
|
|
53
|
-
}));
|
|
54
|
-
subtitles.push(...cues);
|
|
55
|
-
}
|
|
56
|
-
catch (error) {
|
|
57
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
-
warnings.push(`Subtitle stream ${stream.index} extraction failed: ${message}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
await rm(tempDir, { recursive: true, force: true });
|
|
64
|
-
}
|
|
65
|
-
return { subtitles, warnings };
|
|
66
|
-
};
|
package/dist/utils/ffprobe.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
2
|
-
export const parseFfprobeJson = (raw) => {
|
|
3
|
-
const parsed = JSON.parse(raw);
|
|
4
|
-
return {
|
|
5
|
-
streams: Array.isArray(parsed.streams) ? parsed.streams : [],
|
|
6
|
-
format: parsed.format ?? {},
|
|
7
|
-
chapters: Array.isArray(parsed.chapters) ? parsed.chapters : [],
|
|
8
|
-
};
|
|
9
|
-
};
|
|
10
|
-
export const secondsToMs = (value) => {
|
|
11
|
-
if (value === undefined)
|
|
12
|
-
return 0;
|
|
13
|
-
const seconds = typeof value === 'number' ? value : Number.parseFloat(value);
|
|
14
|
-
if (!Number.isFinite(seconds))
|
|
15
|
-
return 0;
|
|
16
|
-
return Math.round(seconds * 1000);
|
|
17
|
-
};
|
|
18
|
-
export const mapStreams = (streams) => streams.map((stream) => ({
|
|
19
|
-
index: stream.index,
|
|
20
|
-
codec_type: stream.codec_type,
|
|
21
|
-
...(stream.codec_name ? { codec_name: stream.codec_name } : {}),
|
|
22
|
-
...(stream.tags?.language ? { language: stream.tags.language } : {}),
|
|
23
|
-
...(stream.channels !== undefined ? { channels: stream.channels } : {}),
|
|
24
|
-
...(stream.sample_rate ? { sample_rate: Number.parseInt(stream.sample_rate, 10) } : {}),
|
|
25
|
-
...(stream.width !== undefined ? { width: stream.width } : {}),
|
|
26
|
-
...(stream.height !== undefined ? { height: stream.height } : {}),
|
|
27
|
-
...(stream.avg_frame_rate ? { avg_frame_rate: stream.avg_frame_rate } : {}),
|
|
28
|
-
...(stream.r_frame_rate ? { r_frame_rate: stream.r_frame_rate } : {}),
|
|
29
|
-
...(stream.bit_rate ? { bit_rate: Number.parseInt(stream.bit_rate, 10) } : {}),
|
|
30
|
-
...(stream.disposition ? { disposition: stream.disposition } : {}),
|
|
31
|
-
...(stream.tags ? { tags: stream.tags } : {}),
|
|
32
|
-
}));
|
|
33
|
-
export const mapChapters = (chapters) => (chapters ?? []).map((chapter) => ({
|
|
34
|
-
id: chapter.id,
|
|
35
|
-
start_ms: secondsToMs(chapter.start),
|
|
36
|
-
end_ms: secondsToMs(chapter.end),
|
|
37
|
-
...(chapter.tags?.title ? { title: chapter.tags.title } : {}),
|
|
38
|
-
}));
|
|
39
|
-
export const collectProbeWarnings = (probe, includeStreams) => {
|
|
40
|
-
const warnings = [];
|
|
41
|
-
const videoStreams = probe.streams.filter((s) => s.codec_type === 'video');
|
|
42
|
-
const audioStreams = probe.streams.filter((s) => s.codec_type === 'audio');
|
|
43
|
-
if (includeStreams && videoStreams.length === 0) {
|
|
44
|
-
warnings.push('No video stream detected.');
|
|
45
|
-
}
|
|
46
|
-
if (includeStreams && audioStreams.length === 0) {
|
|
47
|
-
warnings.push('No audio stream detected.');
|
|
48
|
-
}
|
|
49
|
-
for (const stream of videoStreams) {
|
|
50
|
-
if (stream.avg_frame_rate &&
|
|
51
|
-
stream.r_frame_rate &&
|
|
52
|
-
stream.avg_frame_rate !== stream.r_frame_rate) {
|
|
53
|
-
warnings.push(`Stream ${stream.index}: variable frame rate suspected (avg_frame_rate=${stream.avg_frame_rate}, r_frame_rate=${stream.r_frame_rate}).`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const duration = probe.format.duration;
|
|
57
|
-
if (!duration || Number.parseFloat(duration) <= 0) {
|
|
58
|
-
warnings.push('Duration unavailable or zero; timeline bounds may be incomplete.');
|
|
59
|
-
}
|
|
60
|
-
return warnings;
|
|
61
|
-
};
|
|
62
|
-
export const runFfprobe = async (videoPath) => {
|
|
63
|
-
const available = await isBinaryAvailable('ffprobe');
|
|
64
|
-
if (!available) {
|
|
65
|
-
throw new Error('ffprobe is not installed or not on PATH');
|
|
66
|
-
}
|
|
67
|
-
const { stdout } = await execBinary('ffprobe', [
|
|
68
|
-
'-v',
|
|
69
|
-
'quiet',
|
|
70
|
-
'-print_format',
|
|
71
|
-
'json',
|
|
72
|
-
'-show_format',
|
|
73
|
-
'-show_streams',
|
|
74
|
-
'-show_chapters',
|
|
75
|
-
videoPath,
|
|
76
|
-
], { timeoutMs: 60_000 });
|
|
77
|
-
return parseFfprobeJson(stdout);
|
|
78
|
-
};
|
|
79
|
-
export const findSubtitleStreams = (streams) => streams.filter((stream) => stream.codec_type === 'subtitle');
|
package/dist/utils/pathUtils.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { ErrorCode, VideoError } from './errors.js';
|
|
4
|
-
export const PROJECT_ROOT = process.cwd();
|
|
5
|
-
const canonicalize = (p) => {
|
|
6
|
-
try {
|
|
7
|
-
return fs.realpathSync(p);
|
|
8
|
-
}
|
|
9
|
-
catch (err) {
|
|
10
|
-
if (typeof err === 'object' &&
|
|
11
|
-
err !== null &&
|
|
12
|
-
'code' in err &&
|
|
13
|
-
(err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
14
|
-
const parent = path.dirname(p);
|
|
15
|
-
if (parent === p)
|
|
16
|
-
return p;
|
|
17
|
-
return path.join(canonicalize(parent), path.basename(p));
|
|
18
|
-
}
|
|
19
|
-
throw err;
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
export const resolvePath = (userPath) => {
|
|
23
|
-
if (typeof userPath !== 'string') {
|
|
24
|
-
throw new VideoError(ErrorCode.InvalidParams, 'Path must be a string.');
|
|
25
|
-
}
|
|
26
|
-
const normalizedUserPath = path.normalize(userPath);
|
|
27
|
-
const resolved = path.isAbsolute(normalizedUserPath)
|
|
28
|
-
? normalizedUserPath
|
|
29
|
-
: path.resolve(PROJECT_ROOT, normalizedUserPath);
|
|
30
|
-
return canonicalize(resolved);
|
|
31
|
-
};
|
package/dist/utils/scenes.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { execBinary, isBinaryAvailable } from './exec.js';
|
|
2
|
-
const SCENE_PTS_TIME_RE = /pts_time:([0-9.]+)/g;
|
|
3
|
-
export const parseSceneFilterOutput = (stderr, threshold) => {
|
|
4
|
-
const scenes = [];
|
|
5
|
-
let match;
|
|
6
|
-
while ((match = SCENE_PTS_TIME_RE.exec(stderr)) !== null) {
|
|
7
|
-
const seconds = Number.parseFloat(match[1]);
|
|
8
|
-
if (!Number.isFinite(seconds))
|
|
9
|
-
continue;
|
|
10
|
-
scenes.push({
|
|
11
|
-
index: scenes.length,
|
|
12
|
-
time_ms: Math.round(seconds * 1000),
|
|
13
|
-
provenance: {
|
|
14
|
-
method: 'ffmpeg_scene_filter',
|
|
15
|
-
threshold,
|
|
16
|
-
},
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
return scenes;
|
|
20
|
-
};
|
|
21
|
-
export const detectScenes = async (videoPath, threshold) => {
|
|
22
|
-
const available = await isBinaryAvailable('ffmpeg');
|
|
23
|
-
if (!available) {
|
|
24
|
-
return {
|
|
25
|
-
scenes: [],
|
|
26
|
-
warning: 'ffmpeg is not installed; scene detection skipped.',
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
const { stderr } = await execBinary('ffmpeg', [
|
|
31
|
-
'-hide_banner',
|
|
32
|
-
'-i',
|
|
33
|
-
videoPath,
|
|
34
|
-
'-vf',
|
|
35
|
-
`select='gt(scene,${threshold})',showinfo`,
|
|
36
|
-
'-f',
|
|
37
|
-
'null',
|
|
38
|
-
'-',
|
|
39
|
-
], { timeoutMs: 300_000 });
|
|
40
|
-
return { scenes: parseSceneFilterOutput(stderr, threshold) };
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
-
return {
|
|
45
|
-
scenes: [],
|
|
46
|
-
warning: `Scene detection failed: ${message}`,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
};
|
package/dist/utils/subtitles.js
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
const parseTimestampToMs = (timestamp) => {
|
|
2
|
-
const normalized = timestamp.trim().replace(',', '.');
|
|
3
|
-
const parts = normalized.split(':');
|
|
4
|
-
if (parts.length !== 3)
|
|
5
|
-
return 0;
|
|
6
|
-
const [hours, minutes, secondsPart] = parts;
|
|
7
|
-
const [seconds, millis = '0'] = secondsPart.split('.');
|
|
8
|
-
const h = Number.parseInt(hours, 10);
|
|
9
|
-
const m = Number.parseInt(minutes, 10);
|
|
10
|
-
const s = Number.parseInt(seconds, 10);
|
|
11
|
-
const ms = Number.parseInt(millis.padEnd(3, '0').slice(0, 3), 10);
|
|
12
|
-
return ((h * 60 + m) * 60 + s) * 1000 + ms;
|
|
13
|
-
};
|
|
14
|
-
const stripTags = (text) => text
|
|
15
|
-
.replace(/<[^>]+>/g, '')
|
|
16
|
-
.replace(/\{[^}]+\}/g, '')
|
|
17
|
-
.trim();
|
|
18
|
-
export const parseSrt = (content) => {
|
|
19
|
-
const blocks = content
|
|
20
|
-
.replace(/\r\n/g, '\n')
|
|
21
|
-
.split(/\n{2,}/)
|
|
22
|
-
.map((block) => block.trim())
|
|
23
|
-
.filter(Boolean);
|
|
24
|
-
const cues = [];
|
|
25
|
-
for (const block of blocks) {
|
|
26
|
-
const lines = block.split('\n');
|
|
27
|
-
if (lines.length < 2)
|
|
28
|
-
continue;
|
|
29
|
-
const timingLine = lines.find((line) => line.includes('-->'));
|
|
30
|
-
if (!timingLine)
|
|
31
|
-
continue;
|
|
32
|
-
const [startRaw, endRaw] = timingLine.split('-->').map((part) => part.trim());
|
|
33
|
-
const textLines = lines.filter((line) => line !== lines[0] && !line.includes('-->'));
|
|
34
|
-
const text = stripTags(textLines.join('\n'));
|
|
35
|
-
if (!text)
|
|
36
|
-
continue;
|
|
37
|
-
cues.push({
|
|
38
|
-
index: cues.length,
|
|
39
|
-
start_ms: parseTimestampToMs(startRaw),
|
|
40
|
-
end_ms: parseTimestampToMs(endRaw),
|
|
41
|
-
text,
|
|
42
|
-
provenance: {
|
|
43
|
-
method: 'embedded_parse',
|
|
44
|
-
format: 'srt',
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
return cues;
|
|
49
|
-
};
|
|
50
|
-
export const parseVtt = (content) => {
|
|
51
|
-
const normalized = content.replace(/\r\n/g, '\n');
|
|
52
|
-
const blocks = normalized
|
|
53
|
-
.split(/\n{2,}/)
|
|
54
|
-
.map((block) => block.trim())
|
|
55
|
-
.filter((block) => block && !block.startsWith('WEBVTT') && !block.startsWith('NOTE'));
|
|
56
|
-
const cues = [];
|
|
57
|
-
for (const block of blocks) {
|
|
58
|
-
const lines = block.split('\n');
|
|
59
|
-
const timingLine = lines.find((line) => line.includes('-->'));
|
|
60
|
-
if (!timingLine)
|
|
61
|
-
continue;
|
|
62
|
-
const timing = timingLine.split('-->')[0]?.trim() ?? '';
|
|
63
|
-
const endTiming = timingLine.split('-->')[1]?.trim().split(' ')[0] ?? '';
|
|
64
|
-
const textLines = lines.filter((line) => !line.includes('-->') && !/^\d+$/.test(line));
|
|
65
|
-
const text = stripTags(textLines.join('\n'));
|
|
66
|
-
if (!text)
|
|
67
|
-
continue;
|
|
68
|
-
cues.push({
|
|
69
|
-
index: cues.length,
|
|
70
|
-
start_ms: parseTimestampToMs(timing),
|
|
71
|
-
end_ms: parseTimestampToMs(endTiming),
|
|
72
|
-
text,
|
|
73
|
-
provenance: {
|
|
74
|
-
method: 'embedded_parse',
|
|
75
|
-
format: 'vtt',
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return cues;
|
|
80
|
-
};
|
|
81
|
-
export const parseSubtitleContent = (content, format) => {
|
|
82
|
-
if (format === 'srt')
|
|
83
|
-
return parseSrt(content);
|
|
84
|
-
return parseVtt(content);
|
|
85
|
-
};
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
2
|
-
import { tryAsrTranscript } from '../utils/asr.js';
|
|
3
|
-
import { extractSubtitles } from '../utils/ffmpeg.js';
|
|
4
|
-
import { collectProbeWarnings, findSubtitleStreams, mapChapters, mapStreams, runFfprobe, secondsToMs, } from '../utils/ffprobe.js';
|
|
5
|
-
import { resolvePath } from '../utils/pathUtils.js';
|
|
6
|
-
import { detectScenes } from '../utils/scenes.js';
|
|
7
|
-
const DEFAULT_SCENE_THRESHOLD = 0.4;
|
|
8
|
-
export const buildTimelineDocument = async (sourcePath, args, version) => {
|
|
9
|
-
const includeStreams = args.include_streams ?? true;
|
|
10
|
-
const includeChapters = args.include_chapters ?? true;
|
|
11
|
-
const includeSubtitles = args.include_subtitles ?? true;
|
|
12
|
-
const includeScenes = args.include_scenes ?? true;
|
|
13
|
-
const includeTranscript = args.include_transcript ?? false;
|
|
14
|
-
const sceneThreshold = args.scene_threshold ?? DEFAULT_SCENE_THRESHOLD;
|
|
15
|
-
const warnings = [];
|
|
16
|
-
const probe = await runFfprobe(sourcePath);
|
|
17
|
-
warnings.push(...collectProbeWarnings(probe, includeStreams));
|
|
18
|
-
const format = {
|
|
19
|
-
...(probe.format.format_name ? { format_name: probe.format.format_name } : {}),
|
|
20
|
-
duration_ms: secondsToMs(probe.format.duration),
|
|
21
|
-
...(probe.format.bit_rate ? { bit_rate: Number.parseInt(probe.format.bit_rate, 10) } : {}),
|
|
22
|
-
...(probe.format.size ? { size_bytes: Number.parseInt(probe.format.size, 10) } : {}),
|
|
23
|
-
...(probe.format.tags ? { tags: probe.format.tags } : {}),
|
|
24
|
-
};
|
|
25
|
-
let subtitles = [];
|
|
26
|
-
if (includeSubtitles) {
|
|
27
|
-
const subtitleStreams = findSubtitleStreams(probe.streams);
|
|
28
|
-
const extracted = await extractSubtitles(sourcePath, subtitleStreams);
|
|
29
|
-
subtitles = extracted.subtitles;
|
|
30
|
-
warnings.push(...extracted.warnings);
|
|
31
|
-
}
|
|
32
|
-
let scenes = [];
|
|
33
|
-
if (includeScenes) {
|
|
34
|
-
const detected = await detectScenes(sourcePath, sceneThreshold);
|
|
35
|
-
scenes = detected.scenes;
|
|
36
|
-
if (detected.warning)
|
|
37
|
-
warnings.push(detected.warning);
|
|
38
|
-
}
|
|
39
|
-
let transcript = [];
|
|
40
|
-
if (includeTranscript) {
|
|
41
|
-
const asr = await tryAsrTranscript(sourcePath, true);
|
|
42
|
-
transcript = asr.transcript;
|
|
43
|
-
if (asr.warning)
|
|
44
|
-
warnings.push(asr.warning);
|
|
45
|
-
}
|
|
46
|
-
return {
|
|
47
|
-
provenance: {
|
|
48
|
-
source: sourcePath,
|
|
49
|
-
tool: 'read_video',
|
|
50
|
-
version,
|
|
51
|
-
extracted_at: new Date().toISOString(),
|
|
52
|
-
},
|
|
53
|
-
format,
|
|
54
|
-
streams: includeStreams ? mapStreams(probe.streams) : [],
|
|
55
|
-
chapters: includeChapters ? mapChapters(probe.chapters) : [],
|
|
56
|
-
scenes,
|
|
57
|
-
subtitles,
|
|
58
|
-
transcript,
|
|
59
|
-
warnings,
|
|
60
|
-
};
|
|
61
|
-
};
|
|
62
|
-
export const processVideoSource = async (userPath, args, version) => {
|
|
63
|
-
try {
|
|
64
|
-
const sourcePath = resolvePath(userPath);
|
|
65
|
-
await access(sourcePath);
|
|
66
|
-
const data = await buildTimelineDocument(sourcePath, args, version);
|
|
67
|
-
return {
|
|
68
|
-
source: userPath,
|
|
69
|
-
success: true,
|
|
70
|
-
data,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
-
return {
|
|
76
|
-
source: userPath,
|
|
77
|
-
success: false,
|
|
78
|
-
error: message,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
};
|