@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.0
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 +198 -145
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +136 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +32 -0
- package/dist/tool-handlers/fetch-utils.js +216 -12
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +48 -10
- package/dist/tool-handlers/generate-image.js +148 -33
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +15 -5
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +10 -6
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +15 -4
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +229 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output-path sandbox. Tools that write files (`generate_image`,
|
|
3
|
+
* `generate_audio`, future `generate_video`) route their `save_path`
|
|
4
|
+
* through `resolveSafeOutputPath` so an untrusted MCP caller cannot
|
|
5
|
+
* traverse outside the configured output root.
|
|
6
|
+
*
|
|
7
|
+
* Root resolution order:
|
|
8
|
+
* 1. `OPENROUTER_OUTPUT_DIR` env var (if set and non-empty).
|
|
9
|
+
* 2. `process.cwd()`.
|
|
10
|
+
*
|
|
11
|
+
* Set `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to disable the sandbox entirely
|
|
12
|
+
* (legacy v2 behavior). This is discouraged — document the trade-off
|
|
13
|
+
* where it appears in user configs.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
function getOutputRoot() {
|
|
18
|
+
const override = process.env.OPENROUTER_OUTPUT_DIR;
|
|
19
|
+
if (override && override.length > 0)
|
|
20
|
+
return path.resolve(override);
|
|
21
|
+
return process.cwd();
|
|
22
|
+
}
|
|
23
|
+
function isUnsafeMode() {
|
|
24
|
+
const v = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS;
|
|
25
|
+
return v === '1' || v?.toLowerCase() === 'true';
|
|
26
|
+
}
|
|
27
|
+
export class UnsafeOutputPathError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'UnsafeOutputPathError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve and validate a caller-supplied output path. Creates the parent
|
|
35
|
+
* directory if needed. Returns the absolute path that is safe to write.
|
|
36
|
+
*
|
|
37
|
+
* Throws `UnsafeOutputPathError` when the resolved path escapes the root
|
|
38
|
+
* (traversal attempt) and the sandbox is enabled.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveSafeOutputPath(savePath) {
|
|
41
|
+
if (isUnsafeMode()) {
|
|
42
|
+
const abs = path.resolve(savePath);
|
|
43
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
44
|
+
return abs;
|
|
45
|
+
}
|
|
46
|
+
const root = getOutputRoot();
|
|
47
|
+
const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
|
|
48
|
+
// Resolve relative paths against the real root; absolute paths stay as
|
|
49
|
+
// given so we can check them against the root prefix below.
|
|
50
|
+
const candidate = path.isAbsolute(savePath)
|
|
51
|
+
? path.resolve(savePath)
|
|
52
|
+
: path.resolve(rootReal, savePath);
|
|
53
|
+
// Walk up from the candidate dir to find the first component that exists
|
|
54
|
+
// so we can realpath it. This lets us create new subdirectories under the
|
|
55
|
+
// root while still catching symlink-based traversal.
|
|
56
|
+
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
57
|
+
const candidateDir = path.dirname(candidate);
|
|
58
|
+
const existingAncestor = await findExistingAncestor(candidateDir);
|
|
59
|
+
const ancestorReal = await fs.realpath(existingAncestor);
|
|
60
|
+
// The realpath of the first-existing ancestor MUST be within the root.
|
|
61
|
+
if (!(ancestorReal === rootReal || ancestorReal.startsWith(withSep))) {
|
|
62
|
+
throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR (${rootReal}). ` +
|
|
63
|
+
`Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.`);
|
|
64
|
+
}
|
|
65
|
+
// Safe to create missing intermediate directories now.
|
|
66
|
+
await fs.mkdir(candidateDir, { recursive: true });
|
|
67
|
+
// Re-realpath the final parent in case mkdir traversed a symlink.
|
|
68
|
+
const parentReal = await fs.realpath(candidateDir);
|
|
69
|
+
if (!(parentReal === rootReal || parentReal.startsWith(withSep))) {
|
|
70
|
+
throw new UnsafeOutputPathError(`save_path escapes OPENROUTER_OUTPUT_DIR via symlink (${rootReal}).`);
|
|
71
|
+
}
|
|
72
|
+
return path.join(parentReal, path.basename(candidate));
|
|
73
|
+
}
|
|
74
|
+
async function findExistingAncestor(dir) {
|
|
75
|
+
let current = dir;
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(current);
|
|
79
|
+
return current;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
const parent = path.dirname(current);
|
|
83
|
+
if (parent === current)
|
|
84
|
+
return current; // reached root
|
|
85
|
+
current = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -12,16 +12,9 @@ export declare function handleSearchModels(request: {
|
|
|
12
12
|
params: {
|
|
13
13
|
arguments: SearchModelsArgs;
|
|
14
14
|
};
|
|
15
|
-
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<{
|
|
15
|
+
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | {
|
|
16
16
|
content: {
|
|
17
|
-
type:
|
|
17
|
+
type: "text";
|
|
18
18
|
text: string;
|
|
19
19
|
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
|
-
} | {
|
|
22
|
-
content: {
|
|
23
|
-
type: string;
|
|
24
|
-
text: string;
|
|
25
|
-
}[];
|
|
26
|
-
isError: boolean;
|
|
27
20
|
}>;
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { ErrorCode, toolErrorFrom } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleSearchModels(request, apiClient, modelCache) {
|
|
2
4
|
try {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
return classifyUpstreamError(error, 'search_models');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const results = modelCache.search(request.params.arguments ?? {});
|
|
7
12
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
8
13
|
}
|
|
9
14
|
catch (error) {
|
|
10
|
-
|
|
11
|
-
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
15
|
+
return toolErrorFrom(ErrorCode.INTERNAL, error, 'search_models');
|
|
12
16
|
}
|
|
13
17
|
}
|
|
@@ -6,16 +6,9 @@ export declare function handleValidateModel(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
10
10
|
content: {
|
|
11
|
-
type:
|
|
11
|
+
type: "text";
|
|
12
12
|
text: string;
|
|
13
13
|
}[];
|
|
14
|
-
isError: boolean;
|
|
15
|
-
} | {
|
|
16
|
-
content: {
|
|
17
|
-
type: string;
|
|
18
|
-
text: string;
|
|
19
|
-
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
14
|
}>;
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleValidateModel(request, modelCache, apiClient) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
+
const { model } = request.params.arguments ?? { model: '' };
|
|
5
|
+
if (!model || typeof model !== 'string') {
|
|
6
|
+
return toolError(ErrorCode.INVALID_INPUT, 'model is required.');
|
|
7
|
+
}
|
|
8
|
+
if (apiClient) {
|
|
9
|
+
try {
|
|
10
|
+
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return classifyUpstreamError(error, 'validate_model');
|
|
14
|
+
}
|
|
4
15
|
}
|
|
5
16
|
if (!modelCache.isValid()) {
|
|
6
|
-
return
|
|
17
|
+
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
7
18
|
}
|
|
8
19
|
return {
|
|
9
20
|
content: [
|
|
10
21
|
{
|
|
11
22
|
type: 'text',
|
|
12
|
-
text: JSON.stringify({ valid: modelCache.has(
|
|
23
|
+
text: JSON.stringify({ valid: modelCache.has(model) }),
|
|
13
24
|
},
|
|
14
25
|
],
|
|
15
26
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
|
|
2
|
+
export declare const SUPPORTED_VIDEO_FORMATS: readonly ["mp4", "mpeg", "mov", "webm"];
|
|
3
|
+
export type VideoFormat = (typeof SUPPORTED_VIDEO_FORMATS)[number];
|
|
4
|
+
/** Map a file extension (without the dot) to a canonical VideoFormat. */
|
|
5
|
+
export declare function getVideoFormat(filePath: string): VideoFormat | undefined;
|
|
6
|
+
/** Canonical MIME type for each format. */
|
|
7
|
+
export declare function getVideoMimeType(format: VideoFormat): string;
|
|
8
|
+
/**
|
|
9
|
+
* Detect a container from the first bytes of a buffer. Recognizes mp4/mov
|
|
10
|
+
* (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
|
|
11
|
+
* (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
|
|
12
|
+
*
|
|
13
|
+
* Intentionally conservative: if the magic doesn't match, the caller falls
|
|
14
|
+
* back to the filename / Content-Type.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectVideoFormat(buffer: Buffer): VideoFormat | undefined;
|
|
17
|
+
export interface VideoData {
|
|
18
|
+
data: string;
|
|
19
|
+
format: VideoFormat;
|
|
20
|
+
mediaType: string;
|
|
21
|
+
sizeBytes: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Prepare a video from any source (data URL / HTTP URL / local file) as
|
|
25
|
+
* base64 + MIME. OpenRouter requires the client to send video as either a
|
|
26
|
+
* URL or a data URL; we always base64 it so the tool works regardless of
|
|
27
|
+
* provider quirks.
|
|
28
|
+
*/
|
|
29
|
+
export declare function prepareVideoData(source: string): Promise<VideoData>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video format detection and fetch utilities. Mirrors the structure of
|
|
3
|
+
* `audio-utils.ts`: all network/security logic comes from `fetch-utils.ts`,
|
|
4
|
+
* this module owns format detection, base64 encoding, and MIME mapping.
|
|
5
|
+
*
|
|
6
|
+
* OpenRouter's video-understanding docs (accessed 2026-04-20) list four
|
|
7
|
+
* supported container formats: mp4, mpeg, mov, webm.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
|
|
12
|
+
export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
|
|
13
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
14
|
+
const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
15
|
+
const DEFAULT_MAX_REDIRECTS = 8;
|
|
16
|
+
const DEFAULT_MAX_DATA_URL_BYTES = 100 * 1024 * 1024;
|
|
17
|
+
function getFetchTimeoutMs() {
|
|
18
|
+
return readEnvInt('OPENROUTER_VIDEO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
|
|
19
|
+
}
|
|
20
|
+
function getMaxDownloadBytes() {
|
|
21
|
+
return readEnvInt('OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
|
|
22
|
+
}
|
|
23
|
+
function getMaxRedirects() {
|
|
24
|
+
return readEnvInt('OPENROUTER_VIDEO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
|
|
25
|
+
}
|
|
26
|
+
function getMaxDataUrlBytes() {
|
|
27
|
+
return readEnvInt('OPENROUTER_VIDEO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
|
|
28
|
+
}
|
|
29
|
+
export const SUPPORTED_VIDEO_FORMATS = ['mp4', 'mpeg', 'mov', 'webm'];
|
|
30
|
+
/** Map a file extension (without the dot) to a canonical VideoFormat. */
|
|
31
|
+
export function getVideoFormat(filePath) {
|
|
32
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
33
|
+
const alias = {
|
|
34
|
+
mp4: 'mp4',
|
|
35
|
+
m4v: 'mp4',
|
|
36
|
+
mpeg: 'mpeg',
|
|
37
|
+
mpg: 'mpeg',
|
|
38
|
+
mov: 'mov',
|
|
39
|
+
qt: 'mov',
|
|
40
|
+
webm: 'webm',
|
|
41
|
+
};
|
|
42
|
+
return alias[ext];
|
|
43
|
+
}
|
|
44
|
+
/** Canonical MIME type for each format. */
|
|
45
|
+
export function getVideoMimeType(format) {
|
|
46
|
+
const map = {
|
|
47
|
+
mp4: 'video/mp4',
|
|
48
|
+
mpeg: 'video/mpeg',
|
|
49
|
+
mov: 'video/mov',
|
|
50
|
+
webm: 'video/webm',
|
|
51
|
+
};
|
|
52
|
+
return map[format];
|
|
53
|
+
}
|
|
54
|
+
function mimeSubtypeToFormat(subtype) {
|
|
55
|
+
const aliasMap = {
|
|
56
|
+
mp4: 'mp4',
|
|
57
|
+
'x-m4v': 'mp4',
|
|
58
|
+
mpeg: 'mpeg',
|
|
59
|
+
mov: 'mov',
|
|
60
|
+
quicktime: 'mov',
|
|
61
|
+
'x-quicktime': 'mov',
|
|
62
|
+
webm: 'webm',
|
|
63
|
+
};
|
|
64
|
+
const lower = subtype.toLowerCase();
|
|
65
|
+
return aliasMap[lower];
|
|
66
|
+
}
|
|
67
|
+
function formatFromContentType(ct) {
|
|
68
|
+
if (!ct)
|
|
69
|
+
return undefined;
|
|
70
|
+
const mime = ct.split(';')[0].trim().toLowerCase();
|
|
71
|
+
if (!mime.startsWith('video/'))
|
|
72
|
+
return undefined;
|
|
73
|
+
return mimeSubtypeToFormat(mime.slice(6));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Detect a container from the first bytes of a buffer. Recognizes mp4/mov
|
|
77
|
+
* (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
|
|
78
|
+
* (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
|
|
79
|
+
*
|
|
80
|
+
* Intentionally conservative: if the magic doesn't match, the caller falls
|
|
81
|
+
* back to the filename / Content-Type.
|
|
82
|
+
*/
|
|
83
|
+
export function detectVideoFormat(buffer) {
|
|
84
|
+
if (buffer.length >= 12) {
|
|
85
|
+
const box = buffer.subarray(4, 8).toString('ascii');
|
|
86
|
+
if (box === 'ftyp') {
|
|
87
|
+
// Brand tells mp4 vs mov. 'qt ' and 'mov ' indicate QuickTime.
|
|
88
|
+
const brand = buffer.subarray(8, 12).toString('ascii');
|
|
89
|
+
if (brand === 'qt ' || brand === 'mov ')
|
|
90
|
+
return 'mov';
|
|
91
|
+
return 'mp4';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (buffer.length >= 4) {
|
|
95
|
+
// EBML header — WebM & Matroska.
|
|
96
|
+
if (buffer[0] === 0x1a &&
|
|
97
|
+
buffer[1] === 0x45 &&
|
|
98
|
+
buffer[2] === 0xdf &&
|
|
99
|
+
buffer[3] === 0xa3) {
|
|
100
|
+
return 'webm';
|
|
101
|
+
}
|
|
102
|
+
// MPEG-PS / MPEG-TS start codes.
|
|
103
|
+
if (buffer[0] === 0x00 &&
|
|
104
|
+
buffer[1] === 0x00 &&
|
|
105
|
+
buffer[2] === 0x01 &&
|
|
106
|
+
(buffer[3] === 0xba || buffer[3] === 0xb3 || buffer[3] === 0xe0)) {
|
|
107
|
+
return 'mpeg';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Prepare a video from any source (data URL / HTTP URL / local file) as
|
|
114
|
+
* base64 + MIME. OpenRouter requires the client to send video as either a
|
|
115
|
+
* URL or a data URL; we always base64 it so the tool works regardless of
|
|
116
|
+
* provider quirks.
|
|
117
|
+
*/
|
|
118
|
+
export async function prepareVideoData(source) {
|
|
119
|
+
// --- data URL ---
|
|
120
|
+
if (source.startsWith('data:')) {
|
|
121
|
+
const parsed = parseBase64DataUrl(source);
|
|
122
|
+
if (!parsed)
|
|
123
|
+
throw new Error('Invalid video data URL');
|
|
124
|
+
if (!parsed.mediaType.startsWith('video/')) {
|
|
125
|
+
throw new Error(`Data URL is not a video/* MIME: ${parsed.mediaType}`);
|
|
126
|
+
}
|
|
127
|
+
const format = mimeSubtypeToFormat(parsed.mediaType.slice(6));
|
|
128
|
+
if (!format) {
|
|
129
|
+
throw new Error(`Unsupported video format from MIME: ${parsed.mediaType}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
|
|
132
|
+
if (approxBytes > getMaxDataUrlBytes())
|
|
133
|
+
throw new Error('Video data URL too large');
|
|
134
|
+
return {
|
|
135
|
+
data: parsed.base64,
|
|
136
|
+
format,
|
|
137
|
+
mediaType: getVideoMimeType(format),
|
|
138
|
+
sizeBytes: approxBytes,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// --- HTTP(S) URL ---
|
|
142
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
143
|
+
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
144
|
+
timeoutMs: getFetchTimeoutMs(),
|
|
145
|
+
maxBytes: getMaxDownloadBytes(),
|
|
146
|
+
maxRedirects: getMaxRedirects(),
|
|
147
|
+
});
|
|
148
|
+
const urlPath = new URL(source).pathname;
|
|
149
|
+
const format = detectVideoFormat(buffer) ??
|
|
150
|
+
getVideoFormat(urlPath) ??
|
|
151
|
+
formatFromContentType(contentType);
|
|
152
|
+
if (!format) {
|
|
153
|
+
throw new Error(`Could not determine video format from ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
data: buffer.toString('base64'),
|
|
157
|
+
format,
|
|
158
|
+
mediaType: getVideoMimeType(format),
|
|
159
|
+
sizeBytes: buffer.length,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// --- local file ---
|
|
163
|
+
const buffer = await fs.readFile(source);
|
|
164
|
+
const format = detectVideoFormat(buffer) ?? getVideoFormat(source);
|
|
165
|
+
if (!format) {
|
|
166
|
+
throw new Error(`Unsupported video format for file: ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
data: buffer.toString('base64'),
|
|
170
|
+
format,
|
|
171
|
+
mediaType: getVideoMimeType(format),
|
|
172
|
+
sizeBytes: buffer.length,
|
|
173
|
+
};
|
|
174
|
+
}
|