@sylphx/video-reader-mcp 0.1.0 → 0.1.2
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 +229 -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 +79 -0
- package/src/schemas/videoEvidence.ts +53 -0
- package/src/sdk.ts +44 -0
- package/src/types/timeline.ts +119 -0
- package/src/utils/agentIndex.ts +107 -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/structuralKeyframes.ts +73 -0
- package/src/utils/subtitles.ts +101 -0
- package/src/video/readCoordinator.ts +240 -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/mcp.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
export const text = (value) => ({ type: 'text', text: value });
|
|
8
|
-
export const image = (data, mimeType) => ({
|
|
9
|
-
type: 'image',
|
|
10
|
-
data,
|
|
11
|
-
mimeType,
|
|
12
|
-
});
|
|
13
|
-
export const toolError = (message) => ({
|
|
14
|
-
content: [text(message)],
|
|
15
|
-
isError: true,
|
|
16
|
-
});
|
|
17
|
-
class ToolBuilder {
|
|
18
|
-
#description;
|
|
19
|
-
#inputSchema;
|
|
20
|
-
constructor(descriptionValue, inputSchema) {
|
|
21
|
-
this.#description = descriptionValue;
|
|
22
|
-
this.#inputSchema = inputSchema;
|
|
23
|
-
}
|
|
24
|
-
description(value) {
|
|
25
|
-
return new ToolBuilder(value, this.#inputSchema);
|
|
26
|
-
}
|
|
27
|
-
input(schema) {
|
|
28
|
-
return new ToolBuilder(this.#description, schema);
|
|
29
|
-
}
|
|
30
|
-
handler(handler) {
|
|
31
|
-
return {
|
|
32
|
-
description: this.#description ?? '',
|
|
33
|
-
inputSchema: this.#inputSchema ?? z.object({}),
|
|
34
|
-
handler,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export const tool = () => new ToolBuilder();
|
|
39
|
-
export const stdio = () => ({ kind: 'stdio' });
|
|
40
|
-
export const http = (config) => ({ kind: 'http', ...config });
|
|
41
|
-
const isCallToolResult = (result) => typeof result === 'object' && result !== null && 'content' in result;
|
|
42
|
-
const isContentArray = (result) => Array.isArray(result);
|
|
43
|
-
const normalizeToolResult = (result) => {
|
|
44
|
-
if (isContentArray(result))
|
|
45
|
-
return { content: [...result] };
|
|
46
|
-
if (isCallToolResult(result))
|
|
47
|
-
return result;
|
|
48
|
-
return { content: [result] };
|
|
49
|
-
};
|
|
50
|
-
const withCors = (res, origin) => {
|
|
51
|
-
if (!origin)
|
|
52
|
-
return;
|
|
53
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
54
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
55
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, X-API-Key');
|
|
56
|
-
};
|
|
57
|
-
const isApiKeyValid = (configuredKey, presented) => {
|
|
58
|
-
const provided = Array.isArray(presented) ? presented[0] : presented;
|
|
59
|
-
if (typeof provided !== 'string' || provided.length === 0)
|
|
60
|
-
return false;
|
|
61
|
-
const expected = createHash('sha256').update(configuredKey).digest();
|
|
62
|
-
const actual = createHash('sha256').update(provided).digest();
|
|
63
|
-
return timingSafeEqual(expected, actual);
|
|
64
|
-
};
|
|
65
|
-
const unauthorized = (res) => {
|
|
66
|
-
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'X-API-Key' });
|
|
67
|
-
res.end(JSON.stringify({
|
|
68
|
-
jsonrpc: '2.0',
|
|
69
|
-
id: null,
|
|
70
|
-
error: { code: -32001, message: 'Unauthorized: missing or invalid X-API-Key header' },
|
|
71
|
-
}));
|
|
72
|
-
};
|
|
73
|
-
const ensureStreamableAcceptHeader = (req) => {
|
|
74
|
-
const accept = req.headers.accept;
|
|
75
|
-
const acceptValues = Array.isArray(accept) ? accept.join(', ') : (accept ?? '');
|
|
76
|
-
if (acceptValues.includes('application/json') && acceptValues.includes('text/event-stream')) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
req.headers.accept = 'application/json, text/event-stream';
|
|
80
|
-
const rawAcceptIndex = req.rawHeaders.findIndex((value, index) => index % 2 === 0 && value.toLowerCase() === 'accept');
|
|
81
|
-
if (rawAcceptIndex >= 0) {
|
|
82
|
-
req.rawHeaders[rawAcceptIndex + 1] = 'application/json, text/event-stream';
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
req.rawHeaders.push('Accept', 'application/json, text/event-stream');
|
|
86
|
-
};
|
|
87
|
-
const handleNonMcpRoute = (req, res, pathname, transportConfig) => {
|
|
88
|
-
if (pathname === '/mcp/health' && req.method === 'GET') {
|
|
89
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
-
res.end(JSON.stringify({ status: 'ok' }));
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
if (pathname !== '/mcp') {
|
|
94
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
95
|
-
res.end(JSON.stringify({ error: 'Not found' }));
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
if (req.method === 'OPTIONS') {
|
|
99
|
-
res.writeHead(204);
|
|
100
|
-
res.end();
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
if (transportConfig.apiKey !== undefined &&
|
|
104
|
-
!isApiKeyValid(transportConfig.apiKey, req.headers['x-api-key'])) {
|
|
105
|
-
unauthorized(res);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
109
|
-
};
|
|
110
|
-
const handleHttpRequest = async (req, res, mcpServer, transportConfig) => {
|
|
111
|
-
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? transportConfig.hostname}`);
|
|
112
|
-
withCors(res, transportConfig.cors);
|
|
113
|
-
if (handleNonMcpRoute(req, res, url.pathname, transportConfig))
|
|
114
|
-
return;
|
|
115
|
-
ensureStreamableAcceptHeader(req);
|
|
116
|
-
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
117
|
-
try {
|
|
118
|
-
await mcpServer.connect(transport);
|
|
119
|
-
await transport.handleRequest(req, res);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
await mcpServer.close();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
const startHttpServer = async (serverInfo, transportConfig) => {
|
|
126
|
-
const mcpServer = buildMcpServer(serverInfo);
|
|
127
|
-
const httpServer = createHttpServer((req, res) => {
|
|
128
|
-
void handleHttpRequest(req, res, mcpServer, transportConfig);
|
|
129
|
-
});
|
|
130
|
-
await new Promise((resolve, reject) => {
|
|
131
|
-
httpServer.once('error', reject);
|
|
132
|
-
httpServer.listen(transportConfig.port, transportConfig.hostname, () => {
|
|
133
|
-
httpServer.off('error', reject);
|
|
134
|
-
resolve();
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
return { mcpServer, httpServer };
|
|
138
|
-
};
|
|
139
|
-
const buildMcpServer = ({ name, version, instructions, tools, }) => {
|
|
140
|
-
const mcpServer = new McpServer({ name, version }, {
|
|
141
|
-
...(instructions ? { instructions } : {}),
|
|
142
|
-
});
|
|
143
|
-
for (const [toolName, definition] of Object.entries(tools)) {
|
|
144
|
-
mcpServer.registerTool(toolName, {
|
|
145
|
-
description: definition.description,
|
|
146
|
-
inputSchema: definition.inputSchema,
|
|
147
|
-
}, async (input, ctx) => normalizeToolResult(await definition.handler({ input, ctx })));
|
|
148
|
-
}
|
|
149
|
-
return mcpServer;
|
|
150
|
-
};
|
|
151
|
-
export const createServer = (options) => {
|
|
152
|
-
let mcpServer;
|
|
153
|
-
let httpServer;
|
|
154
|
-
return {
|
|
155
|
-
async start() {
|
|
156
|
-
if (options.transport.kind === 'stdio') {
|
|
157
|
-
mcpServer = buildMcpServer(options);
|
|
158
|
-
await mcpServer.connect(new StdioServerTransport());
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
const started = await startHttpServer(options, options.transport);
|
|
162
|
-
mcpServer = started.mcpServer;
|
|
163
|
-
httpServer = started.httpServer;
|
|
164
|
-
},
|
|
165
|
-
async close() {
|
|
166
|
-
await mcpServer?.close();
|
|
167
|
-
const serverToClose = httpServer;
|
|
168
|
-
if (!serverToClose)
|
|
169
|
-
return;
|
|
170
|
-
await new Promise((resolve, reject) => {
|
|
171
|
-
serverToClose.close((error) => {
|
|
172
|
-
if (error) {
|
|
173
|
-
reject(error);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
resolve();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
};
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
export const videoSourceSchema = z.object({
|
|
3
|
-
path: z.string().min(1).describe('Path to the local video file (absolute or relative to cwd).'),
|
|
4
|
-
});
|
|
5
|
-
export const readVideoArgsSchema = z.object({
|
|
6
|
-
sources: z.array(videoSourceSchema).min(1).describe('One or more local video sources to read.'),
|
|
7
|
-
include_streams: z
|
|
8
|
-
.boolean()
|
|
9
|
-
.optional()
|
|
10
|
-
.describe('Include stream metadata from ffprobe. Defaults to true.'),
|
|
11
|
-
include_chapters: z
|
|
12
|
-
.boolean()
|
|
13
|
-
.optional()
|
|
14
|
-
.describe('Include chapter markers when present. Defaults to true.'),
|
|
15
|
-
include_subtitles: z
|
|
16
|
-
.boolean()
|
|
17
|
-
.optional()
|
|
18
|
-
.describe('Extract embedded subtitles when available. Defaults to true.'),
|
|
19
|
-
include_scenes: z
|
|
20
|
-
.boolean()
|
|
21
|
-
.optional()
|
|
22
|
-
.describe('Detect scene boundaries with ffmpeg scene filter. Defaults to true.'),
|
|
23
|
-
scene_threshold: z
|
|
24
|
-
.number()
|
|
25
|
-
.min(0)
|
|
26
|
-
.max(1)
|
|
27
|
-
.optional()
|
|
28
|
-
.describe('Scene detection sensitivity for ffmpeg gt(scene,threshold). Defaults to 0.4.'),
|
|
29
|
-
include_transcript: z
|
|
30
|
-
.boolean()
|
|
31
|
-
.optional()
|
|
32
|
-
.describe('Attempt optional local ASR transcript when an adapter is installed. Defaults to false.'),
|
|
33
|
-
});
|
package/dist/types/timeline.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/utils/asr.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { isBinaryAvailable } from './exec.js';
|
|
2
|
-
const ASR_CANDIDATES = ['whisper', 'whisper-cpp', 'vosk-transcriber'];
|
|
3
|
-
export const detectAsrAdapter = async () => {
|
|
4
|
-
for (const candidate of ASR_CANDIDATES) {
|
|
5
|
-
if (await isBinaryAvailable(candidate)) {
|
|
6
|
-
return candidate;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
return null;
|
|
10
|
-
};
|
|
11
|
-
export const tryAsrTranscript = async (_videoPath, enabled) => {
|
|
12
|
-
if (!enabled) {
|
|
13
|
-
return { transcript: [] };
|
|
14
|
-
}
|
|
15
|
-
const adapter = await detectAsrAdapter();
|
|
16
|
-
if (!adapter) {
|
|
17
|
-
return {
|
|
18
|
-
transcript: [],
|
|
19
|
-
warning: 'ASR requested but no local adapter found (checked whisper, whisper-cpp, vosk-transcriber); transcript skipped.',
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
return {
|
|
23
|
-
transcript: [],
|
|
24
|
-
warning: `ASR adapter "${adapter}" detected but transcription is not wired in v0.1.0; transcript skipped.`,
|
|
25
|
-
};
|
|
26
|
-
};
|
package/dist/utils/errors.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export var ErrorCode;
|
|
2
|
-
(function (ErrorCode) {
|
|
3
|
-
ErrorCode[ErrorCode["InvalidParams"] = -32602] = "InvalidParams";
|
|
4
|
-
ErrorCode[ErrorCode["InvalidRequest"] = -32600] = "InvalidRequest";
|
|
5
|
-
ErrorCode[ErrorCode["MethodNotFound"] = -32601] = "MethodNotFound";
|
|
6
|
-
})(ErrorCode || (ErrorCode = {}));
|
|
7
|
-
export class VideoError extends Error {
|
|
8
|
-
code;
|
|
9
|
-
constructor(code, message, options) {
|
|
10
|
-
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
11
|
-
this.code = code;
|
|
12
|
-
this.name = 'VideoError';
|
|
13
|
-
}
|
|
14
|
-
}
|
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
|
-
};
|