termux-vision 1.4.6 → 1.5.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 +304 -319
- package/README.pypi.md +132 -121
- package/bin/cli.js +51 -51
- package/lib/vlm.js +349 -349
- package/package.json +55 -55
package/lib/vlm.js
CHANGED
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Node.js VLM Engine & Supervised llama-cli Subprocess Bridge.
|
|
3
|
-
* Open-Source under Apache License 2.0.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const os = require('os');
|
|
11
|
-
const { spawn } = require('child_process');
|
|
12
|
-
|
|
13
|
-
const {
|
|
14
|
-
VulkanNotAvailableError,
|
|
15
|
-
RuntimeNotFoundError,
|
|
16
|
-
TermuxVisionError
|
|
17
|
-
} = require('./errors');
|
|
18
|
-
const { ModelCacheManager } = require('./cache');
|
|
19
|
-
|
|
20
|
-
function isVulkanFailure(text) {
|
|
21
|
-
const lower = String(text).toLowerCase();
|
|
22
|
-
const markers = [
|
|
23
|
-
'vulkan',
|
|
24
|
-
'vk_error',
|
|
25
|
-
'device lost',
|
|
26
|
-
'failed to initialize gpu',
|
|
27
|
-
'no vulkan device',
|
|
28
|
-
'gpu backend',
|
|
29
|
-
'vkcreateinstance',
|
|
30
|
-
'vkcreatedevice',
|
|
31
|
-
'ggml_vulkan'
|
|
32
|
-
];
|
|
33
|
-
return markers.some(m => lower.includes(m));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function resolveLlamaCli(explicitPath = null) {
|
|
37
|
-
if (explicitPath) {
|
|
38
|
-
const p = path.resolve(explicitPath);
|
|
39
|
-
if (fs.existsSync(p)) return p;
|
|
40
|
-
throw new RuntimeNotFoundError([p]);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (process.env.TERMUX_VISION_LLAMA_CLI && fs.existsSync(process.env.TERMUX_VISION_LLAMA_CLI)) {
|
|
44
|
-
return process.env.TERMUX_VISION_LLAMA_CLI;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr';
|
|
48
|
-
const xdgCache = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
49
|
-
const candidates = [
|
|
50
|
-
path.join(xdgCache, 'termux-llamacpp', 'bin', 'llama-cli'),
|
|
51
|
-
path.join(os.homedir(), '.local', 'bin', 'llama-cli'),
|
|
52
|
-
path.join(prefix, 'bin', 'llama-cli'),
|
|
53
|
-
path.join(prefix, 'bin', 'termux-llama-cli'),
|
|
54
|
-
path.join(prefix, 'bin', 'llama-mtmd-cli'),
|
|
55
|
-
path.join(os.homedir(), '.termux-llamacpp', 'current', 'bin', 'llama-cli'),
|
|
56
|
-
path.join(os.homedir(), '.termux-llama', 'current', 'bin', 'llama-cli'),
|
|
57
|
-
path.join(os.homedir(), 'bin', 'llama-cli'),
|
|
58
|
-
'llama-cli'
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
const pathDirs = (process.env.PATH || '').split(path.delimiter);
|
|
62
|
-
for (const dir of pathDirs) {
|
|
63
|
-
const full = path.join(dir, 'llama-cli' + (process.platform === 'win32' ? '.exe' : ''));
|
|
64
|
-
if (fs.existsSync(full)) return full;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
for (const c of candidates) {
|
|
68
|
-
if (fs.existsSync(c)) return c;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
throw new RuntimeNotFoundError(candidates);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
class NodeVLMContext {
|
|
75
|
-
constructor(options) {
|
|
76
|
-
this.manifest = options.manifest;
|
|
77
|
-
this.textModelPath = options.textModelPath;
|
|
78
|
-
this.visionModelPath = options.visionModelPath;
|
|
79
|
-
this.executable = options.executable;
|
|
80
|
-
this.threads = options.threads || 4;
|
|
81
|
-
this.backend = options.backend || 'auto';
|
|
82
|
-
this.fallback = options.fallback !== false;
|
|
83
|
-
this.contextLimit = options.contextLimit || options.manifest.contextLimit || 1024;
|
|
84
|
-
this.customNgl = options.ngl;
|
|
85
|
-
this.closed = false;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async _executeOnce(imagePath, prompt, options, targetBackend) {
|
|
89
|
-
const nglVal = this.customNgl !== undefined && this.customNgl !== null ? String(this.customNgl) : (['vulkan', 'auto', 'gpu'].includes(targetBackend) ? '99' : '0');
|
|
90
|
-
const tmpPrompt = path.join(os.tmpdir(), `tv_prompt_${Date.now()}_${Math.random().toString(36).substring(7)}.txt`);
|
|
91
|
-
|
|
92
|
-
const formattedPrompt = options.systemPrompt ?
|
|
93
|
-
`System: ${options.systemPrompt}\n<image>\nUser: ${prompt}\nAssistant:` :
|
|
94
|
-
`<image>\nUser: ${prompt}\nAssistant:`;
|
|
95
|
-
fs.writeFileSync(tmpPrompt, formattedPrompt, 'utf-8');
|
|
96
|
-
|
|
97
|
-
const cliArgs = [
|
|
98
|
-
'-m', this.textModelPath,
|
|
99
|
-
'--mmproj', this.visionModelPath,
|
|
100
|
-
'--image', imagePath,
|
|
101
|
-
'-f', tmpPrompt,
|
|
102
|
-
'-t', String(this.threads),
|
|
103
|
-
'-c', String(this.contextLimit),
|
|
104
|
-
'-n', String(options.maxTokens || 150),
|
|
105
|
-
'--temp', String(options.temperature !== undefined ? options.temperature : 0.2),
|
|
106
|
-
'-ngl', nglVal,
|
|
107
|
-
'--single-turn',
|
|
108
|
-
'--simple-io'
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
if (['auto', 'vulkan', 'gpu'].includes(targetBackend)) {
|
|
112
|
-
cliArgs.push('--device', targetBackend === 'auto' ? 'auto' : 'vulkan');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (options.repeatPenalty !== undefined) cliArgs.push('--repeat-penalty', String(options.repeatPenalty));
|
|
116
|
-
if (options.topP !== undefined) cliArgs.push('--top-p', String(options.topP));
|
|
117
|
-
if (options.topK !== undefined) cliArgs.push('--top-k', String(options.topK));
|
|
118
|
-
if (options.seed !== undefined) cliArgs.push('-s', String(options.seed));
|
|
119
|
-
|
|
120
|
-
return new Promise((resolve, reject) => {
|
|
121
|
-
const t0 = Date.now();
|
|
122
|
-
const proc = spawn(this.executable, cliArgs, {
|
|
123
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
let stdoutData = '';
|
|
127
|
-
let stderrData = '';
|
|
128
|
-
|
|
129
|
-
proc.stdout.on('data', (d) => { stdoutData += d.toString('utf-8'); });
|
|
130
|
-
proc.stderr.on('data', (d) => { stderrData += d.toString('utf-8'); });
|
|
131
|
-
|
|
132
|
-
proc.on('close', (code, signal) => {
|
|
133
|
-
try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
|
|
134
|
-
const totalMs = Date.now() - t0;
|
|
135
|
-
|
|
136
|
-
if (code !== 0) {
|
|
137
|
-
const errText = (stderrData.trim() || stdoutData.trim());
|
|
138
|
-
if (code === 137 || signal === 'SIGKILL' || code === -9) {
|
|
139
|
-
return reject(new TermuxVisionError(
|
|
140
|
-
`VLM inference process was terminated by system (OOM / LowMemoryKiller / SIGKILL).\n` +
|
|
141
|
-
`[Action Recommendation] Use a smaller model (e.g. smolvlm-500m-q4), reduce threads (-t 2), or close background apps.`
|
|
142
|
-
));
|
|
143
|
-
}
|
|
144
|
-
return reject(new TermuxVisionError(`llama-cli exited with code ${code}: ${errText}`));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const lines = stdoutData.split('\n');
|
|
148
|
-
const contentLines = [];
|
|
149
|
-
let startCapture = false;
|
|
150
|
-
let tps = null;
|
|
151
|
-
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
const trimmed = line.trim();
|
|
154
|
-
if (trimmed.startsWith('>')) {
|
|
155
|
-
startCapture = true;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (startCapture) {
|
|
159
|
-
if (line.includes('Generation:') && line.includes('t/s')) {
|
|
160
|
-
try {
|
|
161
|
-
const match = line.match(/Generation:\s*([0-9.]+)\s*t\/s/);
|
|
162
|
-
if (match) tps = parseFloat(match[1]);
|
|
163
|
-
} catch (e) {}
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
if (line.includes('Exiting') || line.includes('main: image')) continue;
|
|
167
|
-
const cleaned = line.replace(/[|\-\/\\]/g, '').trim();
|
|
168
|
-
if (cleaned) contentLines.push(cleaned);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const textOutput = contentLines.length > 0 ? contentLines.join(' ') : stdoutData.trim();
|
|
173
|
-
resolve({
|
|
174
|
-
text: textOutput,
|
|
175
|
-
finishReason: 'stop',
|
|
176
|
-
wordCount: textOutput.split(/\s+/).filter(Boolean).length,
|
|
177
|
-
metrics: {
|
|
178
|
-
backend: targetBackend,
|
|
179
|
-
modelId: this.manifest.modelId,
|
|
180
|
-
decodeMs: totalMs,
|
|
181
|
-
tokensPerSecond: tps
|
|
182
|
-
},
|
|
183
|
-
warnings: []
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
proc.on('error', (err) => {
|
|
188
|
-
try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
|
|
189
|
-
reject(err);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async describe(imagePath, options = {}) {
|
|
195
|
-
if (this.closed) throw new Error('Cannot describe with closed VLMContext.');
|
|
196
|
-
if (!imagePath || typeof imagePath !== 'string' || !imagePath.trim()) {
|
|
197
|
-
throw new Error("Parameter 'imagePath' cannot be null or empty.");
|
|
198
|
-
}
|
|
199
|
-
const resolvedImg = path.resolve(imagePath.replace(/^~(?=$|\/|\\)/, os.homedir()));
|
|
200
|
-
if (!fs.existsSync(resolvedImg)) {
|
|
201
|
-
throw new Error(`Input image file not found: '${resolvedImg}'`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const prompt = options.prompt || '이 사진 속 인물의 표정, 옷차림, 자세, 그리고 배경 환경을 한국어로 간결하게 요약 설명해줘.';
|
|
205
|
-
if (!prompt || !prompt.trim()) {
|
|
206
|
-
throw new Error("Parameter 'prompt' cannot be empty.");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Strict validation
|
|
210
|
-
if (options.maxTokens !== undefined && options.maxTokens <= 0) {
|
|
211
|
-
throw new Error(`Parameter 'maxTokens' must be > 0. Received: ${options.maxTokens}`);
|
|
212
|
-
}
|
|
213
|
-
if (options.temperature !== undefined && options.temperature < 0) {
|
|
214
|
-
throw new Error(`Parameter 'temperature' must be >= 0. Received: ${options.temperature}`);
|
|
215
|
-
}
|
|
216
|
-
if (options.topP !== undefined && (options.topP <= 0 || options.topP > 1.0)) {
|
|
217
|
-
throw new Error(`Parameter 'topP' must be between (0.0, 1.0]. Received: ${options.topP}`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let targetImg = resolvedImg;
|
|
221
|
-
let tempImg = null;
|
|
222
|
-
const quality = (options.quality || 'optimal').toLowerCase().trim();
|
|
223
|
-
const maxDim = options.maxDim ? parseInt(options.maxDim, 10) : null;
|
|
224
|
-
|
|
225
|
-
if (quality !== 'original' || maxDim !== null) {
|
|
226
|
-
const limit = maxDim || (quality === 'high' ? 1280 : (quality === 'fast' ? 384 : 768));
|
|
227
|
-
try {
|
|
228
|
-
const { spawnSync } = require('child_process');
|
|
229
|
-
const tmpTarget = path.join(os.tmpdir(), `tv_scaled_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`);
|
|
230
|
-
const scalePyCode = 'import sys, termux_vision as tv; img = tv.io.load_image(sys.argv[1]); dims = tv.transforms.resolve_target_dimensions(img.shape[1], img.shape[0], max_dim=int(sys.argv[3])) or (img.shape[1], img.shape[0]); scaled = tv.transforms.resize(img, dims); tv.io.save_image(scaled, sys.argv[2])';
|
|
231
|
-
let scaleRes = spawnSync('python3', [
|
|
232
|
-
'-c',
|
|
233
|
-
scalePyCode,
|
|
234
|
-
resolvedImg,
|
|
235
|
-
tmpTarget,
|
|
236
|
-
String(limit)
|
|
237
|
-
]);
|
|
238
|
-
if (scaleRes.status !== 0 || !fs.existsSync(tmpTarget)) {
|
|
239
|
-
scaleRes = spawnSync('python', [
|
|
240
|
-
'-c',
|
|
241
|
-
scalePyCode,
|
|
242
|
-
resolvedImg,
|
|
243
|
-
tmpTarget,
|
|
244
|
-
String(limit)
|
|
245
|
-
]);
|
|
246
|
-
}
|
|
247
|
-
if (scaleRes.status === 0 && fs.existsSync(tmpTarget)) {
|
|
248
|
-
targetImg = tmpTarget;
|
|
249
|
-
tempImg = tmpTarget;
|
|
250
|
-
}
|
|
251
|
-
} catch (e) {}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
try {
|
|
255
|
-
return await this._executeOnce(targetImg, prompt, options, this.backend);
|
|
256
|
-
} catch (err) {
|
|
257
|
-
if (this.backend === 'vulkan') {
|
|
258
|
-
if (this.fallback && isVulkanFailure(err.message)) {
|
|
259
|
-
const fallbackRes = await this._executeOnce(resolvedImg, prompt, options, 'cpu');
|
|
260
|
-
fallbackRes.warnings.push(`Vulkan execution failed; retried on CPU: ${err.message}`);
|
|
261
|
-
return fallbackRes;
|
|
262
|
-
} else {
|
|
263
|
-
throw new VulkanNotAvailableError(err.message);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
throw err;
|
|
267
|
-
} finally {
|
|
268
|
-
if (tempImg && fs.existsSync(tempImg)) {
|
|
269
|
-
try { fs.unlinkSync(tempImg); } catch (e) {}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async ask(imagePath, question, options = {}) {
|
|
275
|
-
if (!question || !question.trim()) throw new Error("Parameter 'question' cannot be null or empty.");
|
|
276
|
-
const res = await this.describe(imagePath, { ...options, prompt: question });
|
|
277
|
-
return res.text;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
close() {
|
|
281
|
-
this.closed = true;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async function load(options = {}) {
|
|
286
|
-
const cache = new ModelCacheManager(options.cacheRoot || null);
|
|
287
|
-
|
|
288
|
-
if (options.modelId && options.modelPath) {
|
|
289
|
-
throw new Error("Cannot specify both 'modelId' and 'modelPath'. Use 'modelId' for catalog presets or 'modelPath' for explicit .gguf file paths.");
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
let modelInfo;
|
|
293
|
-
if (options.modelPath) {
|
|
294
|
-
const expanded = path.resolve(options.modelPath.replace(/^~(?=$|\/|\\)/, os.homedir()));
|
|
295
|
-
if (!fs.existsSync(expanded) || !fs.statSync(expanded).isFile()) {
|
|
296
|
-
throw new Error(`Model file not found at 'modelPath': ${options.modelPath}`);
|
|
297
|
-
}
|
|
298
|
-
modelInfo = cache.requireInstalledModel(expanded, options.mmprojPath || null);
|
|
299
|
-
} else {
|
|
300
|
-
const modelId = options.modelId || 'smolvlm-500m-q4';
|
|
301
|
-
if (!modelId || !String(modelId).trim()) {
|
|
302
|
-
throw new Error("Parameter 'modelId' cannot be null or empty.");
|
|
303
|
-
}
|
|
304
|
-
if (options.allowDownload && !cache.isModelInstalled(modelId)) {
|
|
305
|
-
await cache.install(modelId);
|
|
306
|
-
}
|
|
307
|
-
modelInfo = cache.requireInstalledModel(modelId, options.mmprojPath || null);
|
|
308
|
-
}
|
|
309
|
-
const executable = resolveLlamaCli(options.runtimePath || null);
|
|
310
|
-
|
|
311
|
-
const reqDevice = (options.device || 'auto').toLowerCase().trim();
|
|
312
|
-
let actualBackend = 'cpu';
|
|
313
|
-
let actualFallback = false;
|
|
314
|
-
|
|
315
|
-
if (reqDevice === 'auto') {
|
|
316
|
-
actualBackend = 'auto';
|
|
317
|
-
actualFallback = true;
|
|
318
|
-
} else if (['vulkan', 'gpu', 'vulkan-force'].includes(reqDevice)) {
|
|
319
|
-
actualBackend = 'vulkan';
|
|
320
|
-
actualFallback = false;
|
|
321
|
-
} else {
|
|
322
|
-
actualBackend = 'cpu';
|
|
323
|
-
actualFallback = false;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
let threads = 4;
|
|
327
|
-
if (typeof options.threads === 'number') {
|
|
328
|
-
if (options.threads <= 0) throw new Error(`Parameter 'threads' must be > 0. Received: ${options.threads}`);
|
|
329
|
-
threads = Math.max(1, Math.min(128, options.threads));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return new NodeVLMContext({
|
|
333
|
-
manifest: modelInfo.manifest,
|
|
334
|
-
textModelPath: modelInfo.textModelPath,
|
|
335
|
-
visionModelPath: modelInfo.visionModelPath,
|
|
336
|
-
executable: executable,
|
|
337
|
-
threads: threads,
|
|
338
|
-
backend: actualBackend,
|
|
339
|
-
fallback: actualFallback,
|
|
340
|
-
contextLimit: options.contextLimit,
|
|
341
|
-
ngl: options.ngl
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
module.exports = {
|
|
346
|
-
load,
|
|
347
|
-
resolveLlamaCli,
|
|
348
|
-
NodeVLMContext
|
|
349
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Node.js VLM Engine & Supervised llama-cli Subprocess Bridge.
|
|
3
|
+
* Open-Source under Apache License 2.0.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
VulkanNotAvailableError,
|
|
15
|
+
RuntimeNotFoundError,
|
|
16
|
+
TermuxVisionError
|
|
17
|
+
} = require('./errors');
|
|
18
|
+
const { ModelCacheManager } = require('./cache');
|
|
19
|
+
|
|
20
|
+
function isVulkanFailure(text) {
|
|
21
|
+
const lower = String(text).toLowerCase();
|
|
22
|
+
const markers = [
|
|
23
|
+
'vulkan',
|
|
24
|
+
'vk_error',
|
|
25
|
+
'device lost',
|
|
26
|
+
'failed to initialize gpu',
|
|
27
|
+
'no vulkan device',
|
|
28
|
+
'gpu backend',
|
|
29
|
+
'vkcreateinstance',
|
|
30
|
+
'vkcreatedevice',
|
|
31
|
+
'ggml_vulkan'
|
|
32
|
+
];
|
|
33
|
+
return markers.some(m => lower.includes(m));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveLlamaCli(explicitPath = null) {
|
|
37
|
+
if (explicitPath) {
|
|
38
|
+
const p = path.resolve(explicitPath);
|
|
39
|
+
if (fs.existsSync(p)) return p;
|
|
40
|
+
throw new RuntimeNotFoundError([p]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (process.env.TERMUX_VISION_LLAMA_CLI && fs.existsSync(process.env.TERMUX_VISION_LLAMA_CLI)) {
|
|
44
|
+
return process.env.TERMUX_VISION_LLAMA_CLI;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr';
|
|
48
|
+
const xdgCache = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
49
|
+
const candidates = [
|
|
50
|
+
path.join(xdgCache, 'termux-llamacpp', 'bin', 'llama-cli'),
|
|
51
|
+
path.join(os.homedir(), '.local', 'bin', 'llama-cli'),
|
|
52
|
+
path.join(prefix, 'bin', 'llama-cli'),
|
|
53
|
+
path.join(prefix, 'bin', 'termux-llama-cli'),
|
|
54
|
+
path.join(prefix, 'bin', 'llama-mtmd-cli'),
|
|
55
|
+
path.join(os.homedir(), '.termux-llamacpp', 'current', 'bin', 'llama-cli'),
|
|
56
|
+
path.join(os.homedir(), '.termux-llama', 'current', 'bin', 'llama-cli'),
|
|
57
|
+
path.join(os.homedir(), 'bin', 'llama-cli'),
|
|
58
|
+
'llama-cli'
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const pathDirs = (process.env.PATH || '').split(path.delimiter);
|
|
62
|
+
for (const dir of pathDirs) {
|
|
63
|
+
const full = path.join(dir, 'llama-cli' + (process.platform === 'win32' ? '.exe' : ''));
|
|
64
|
+
if (fs.existsSync(full)) return full;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
if (fs.existsSync(c)) return c;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new RuntimeNotFoundError(candidates);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class NodeVLMContext {
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.manifest = options.manifest;
|
|
77
|
+
this.textModelPath = options.textModelPath;
|
|
78
|
+
this.visionModelPath = options.visionModelPath;
|
|
79
|
+
this.executable = options.executable;
|
|
80
|
+
this.threads = options.threads || 4;
|
|
81
|
+
this.backend = options.backend || 'auto';
|
|
82
|
+
this.fallback = options.fallback !== false;
|
|
83
|
+
this.contextLimit = options.contextLimit || options.manifest.contextLimit || 1024;
|
|
84
|
+
this.customNgl = options.ngl;
|
|
85
|
+
this.closed = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async _executeOnce(imagePath, prompt, options, targetBackend) {
|
|
89
|
+
const nglVal = this.customNgl !== undefined && this.customNgl !== null ? String(this.customNgl) : (['vulkan', 'auto', 'gpu'].includes(targetBackend) ? '99' : '0');
|
|
90
|
+
const tmpPrompt = path.join(os.tmpdir(), `tv_prompt_${Date.now()}_${Math.random().toString(36).substring(7)}.txt`);
|
|
91
|
+
|
|
92
|
+
const formattedPrompt = options.systemPrompt ?
|
|
93
|
+
`System: ${options.systemPrompt}\n<image>\nUser: ${prompt}\nAssistant:` :
|
|
94
|
+
`<image>\nUser: ${prompt}\nAssistant:`;
|
|
95
|
+
fs.writeFileSync(tmpPrompt, formattedPrompt, 'utf-8');
|
|
96
|
+
|
|
97
|
+
const cliArgs = [
|
|
98
|
+
'-m', this.textModelPath,
|
|
99
|
+
'--mmproj', this.visionModelPath,
|
|
100
|
+
'--image', imagePath,
|
|
101
|
+
'-f', tmpPrompt,
|
|
102
|
+
'-t', String(this.threads),
|
|
103
|
+
'-c', String(this.contextLimit),
|
|
104
|
+
'-n', String(options.maxTokens || 150),
|
|
105
|
+
'--temp', String(options.temperature !== undefined ? options.temperature : 0.2),
|
|
106
|
+
'-ngl', nglVal,
|
|
107
|
+
'--single-turn',
|
|
108
|
+
'--simple-io'
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
if (['auto', 'vulkan', 'gpu'].includes(targetBackend)) {
|
|
112
|
+
cliArgs.push('--device', targetBackend === 'auto' ? 'auto' : 'vulkan');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (options.repeatPenalty !== undefined) cliArgs.push('--repeat-penalty', String(options.repeatPenalty));
|
|
116
|
+
if (options.topP !== undefined) cliArgs.push('--top-p', String(options.topP));
|
|
117
|
+
if (options.topK !== undefined) cliArgs.push('--top-k', String(options.topK));
|
|
118
|
+
if (options.seed !== undefined) cliArgs.push('-s', String(options.seed));
|
|
119
|
+
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const t0 = Date.now();
|
|
122
|
+
const proc = spawn(this.executable, cliArgs, {
|
|
123
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let stdoutData = '';
|
|
127
|
+
let stderrData = '';
|
|
128
|
+
|
|
129
|
+
proc.stdout.on('data', (d) => { stdoutData += d.toString('utf-8'); });
|
|
130
|
+
proc.stderr.on('data', (d) => { stderrData += d.toString('utf-8'); });
|
|
131
|
+
|
|
132
|
+
proc.on('close', (code, signal) => {
|
|
133
|
+
try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
|
|
134
|
+
const totalMs = Date.now() - t0;
|
|
135
|
+
|
|
136
|
+
if (code !== 0) {
|
|
137
|
+
const errText = (stderrData.trim() || stdoutData.trim());
|
|
138
|
+
if (code === 137 || signal === 'SIGKILL' || code === -9) {
|
|
139
|
+
return reject(new TermuxVisionError(
|
|
140
|
+
`VLM inference process was terminated by system (OOM / LowMemoryKiller / SIGKILL).\n` +
|
|
141
|
+
`[Action Recommendation] Use a smaller model (e.g. smolvlm-500m-q4), reduce threads (-t 2), or close background apps.`
|
|
142
|
+
));
|
|
143
|
+
}
|
|
144
|
+
return reject(new TermuxVisionError(`llama-cli exited with code ${code}: ${errText}`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lines = stdoutData.split('\n');
|
|
148
|
+
const contentLines = [];
|
|
149
|
+
let startCapture = false;
|
|
150
|
+
let tps = null;
|
|
151
|
+
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (trimmed.startsWith('>')) {
|
|
155
|
+
startCapture = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (startCapture) {
|
|
159
|
+
if (line.includes('Generation:') && line.includes('t/s')) {
|
|
160
|
+
try {
|
|
161
|
+
const match = line.match(/Generation:\s*([0-9.]+)\s*t\/s/);
|
|
162
|
+
if (match) tps = parseFloat(match[1]);
|
|
163
|
+
} catch (e) {}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (line.includes('Exiting') || line.includes('main: image')) continue;
|
|
167
|
+
const cleaned = line.replace(/[|\-\/\\]/g, '').trim();
|
|
168
|
+
if (cleaned) contentLines.push(cleaned);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const textOutput = contentLines.length > 0 ? contentLines.join(' ') : stdoutData.trim();
|
|
173
|
+
resolve({
|
|
174
|
+
text: textOutput,
|
|
175
|
+
finishReason: 'stop',
|
|
176
|
+
wordCount: textOutput.split(/\s+/).filter(Boolean).length,
|
|
177
|
+
metrics: {
|
|
178
|
+
backend: targetBackend,
|
|
179
|
+
modelId: this.manifest.modelId,
|
|
180
|
+
decodeMs: totalMs,
|
|
181
|
+
tokensPerSecond: tps
|
|
182
|
+
},
|
|
183
|
+
warnings: []
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
proc.on('error', (err) => {
|
|
188
|
+
try { if (fs.existsSync(tmpPrompt)) fs.unlinkSync(tmpPrompt); } catch (e) {}
|
|
189
|
+
reject(err);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async describe(imagePath, options = {}) {
|
|
195
|
+
if (this.closed) throw new Error('Cannot describe with closed VLMContext.');
|
|
196
|
+
if (!imagePath || typeof imagePath !== 'string' || !imagePath.trim()) {
|
|
197
|
+
throw new Error("Parameter 'imagePath' cannot be null or empty.");
|
|
198
|
+
}
|
|
199
|
+
const resolvedImg = path.resolve(imagePath.replace(/^~(?=$|\/|\\)/, os.homedir()));
|
|
200
|
+
if (!fs.existsSync(resolvedImg)) {
|
|
201
|
+
throw new Error(`Input image file not found: '${resolvedImg}'`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const prompt = options.prompt || '이 사진 속 인물의 표정, 옷차림, 자세, 그리고 배경 환경을 한국어로 간결하게 요약 설명해줘.';
|
|
205
|
+
if (!prompt || !prompt.trim()) {
|
|
206
|
+
throw new Error("Parameter 'prompt' cannot be empty.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Strict validation
|
|
210
|
+
if (options.maxTokens !== undefined && options.maxTokens <= 0) {
|
|
211
|
+
throw new Error(`Parameter 'maxTokens' must be > 0. Received: ${options.maxTokens}`);
|
|
212
|
+
}
|
|
213
|
+
if (options.temperature !== undefined && options.temperature < 0) {
|
|
214
|
+
throw new Error(`Parameter 'temperature' must be >= 0. Received: ${options.temperature}`);
|
|
215
|
+
}
|
|
216
|
+
if (options.topP !== undefined && (options.topP <= 0 || options.topP > 1.0)) {
|
|
217
|
+
throw new Error(`Parameter 'topP' must be between (0.0, 1.0]. Received: ${options.topP}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let targetImg = resolvedImg;
|
|
221
|
+
let tempImg = null;
|
|
222
|
+
const quality = (options.quality || 'optimal').toLowerCase().trim();
|
|
223
|
+
const maxDim = options.maxDim ? parseInt(options.maxDim, 10) : null;
|
|
224
|
+
|
|
225
|
+
if (quality !== 'original' || maxDim !== null) {
|
|
226
|
+
const limit = maxDim || (quality === 'high' ? 1280 : (quality === 'fast' ? 384 : 768));
|
|
227
|
+
try {
|
|
228
|
+
const { spawnSync } = require('child_process');
|
|
229
|
+
const tmpTarget = path.join(os.tmpdir(), `tv_scaled_${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`);
|
|
230
|
+
const scalePyCode = 'import sys, termux_vision as tv; img = tv.io.load_image(sys.argv[1]); dims = tv.transforms.resolve_target_dimensions(img.shape[1], img.shape[0], max_dim=int(sys.argv[3])) or (img.shape[1], img.shape[0]); scaled = tv.transforms.resize(img, dims); tv.io.save_image(scaled, sys.argv[2])';
|
|
231
|
+
let scaleRes = spawnSync('python3', [
|
|
232
|
+
'-c',
|
|
233
|
+
scalePyCode,
|
|
234
|
+
resolvedImg,
|
|
235
|
+
tmpTarget,
|
|
236
|
+
String(limit)
|
|
237
|
+
]);
|
|
238
|
+
if (scaleRes.status !== 0 || !fs.existsSync(tmpTarget)) {
|
|
239
|
+
scaleRes = spawnSync('python', [
|
|
240
|
+
'-c',
|
|
241
|
+
scalePyCode,
|
|
242
|
+
resolvedImg,
|
|
243
|
+
tmpTarget,
|
|
244
|
+
String(limit)
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
if (scaleRes.status === 0 && fs.existsSync(tmpTarget)) {
|
|
248
|
+
targetImg = tmpTarget;
|
|
249
|
+
tempImg = tmpTarget;
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
return await this._executeOnce(targetImg, prompt, options, this.backend);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (this.backend === 'vulkan') {
|
|
258
|
+
if (this.fallback && isVulkanFailure(err.message)) {
|
|
259
|
+
const fallbackRes = await this._executeOnce(resolvedImg, prompt, options, 'cpu');
|
|
260
|
+
fallbackRes.warnings.push(`Vulkan execution failed; retried on CPU: ${err.message}`);
|
|
261
|
+
return fallbackRes;
|
|
262
|
+
} else {
|
|
263
|
+
throw new VulkanNotAvailableError(err.message);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
} finally {
|
|
268
|
+
if (tempImg && fs.existsSync(tempImg)) {
|
|
269
|
+
try { fs.unlinkSync(tempImg); } catch (e) {}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async ask(imagePath, question, options = {}) {
|
|
275
|
+
if (!question || !question.trim()) throw new Error("Parameter 'question' cannot be null or empty.");
|
|
276
|
+
const res = await this.describe(imagePath, { ...options, prompt: question });
|
|
277
|
+
return res.text;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
close() {
|
|
281
|
+
this.closed = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function load(options = {}) {
|
|
286
|
+
const cache = new ModelCacheManager(options.cacheRoot || null);
|
|
287
|
+
|
|
288
|
+
if (options.modelId && options.modelPath) {
|
|
289
|
+
throw new Error("Cannot specify both 'modelId' and 'modelPath'. Use 'modelId' for catalog presets or 'modelPath' for explicit .gguf file paths.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let modelInfo;
|
|
293
|
+
if (options.modelPath) {
|
|
294
|
+
const expanded = path.resolve(options.modelPath.replace(/^~(?=$|\/|\\)/, os.homedir()));
|
|
295
|
+
if (!fs.existsSync(expanded) || !fs.statSync(expanded).isFile()) {
|
|
296
|
+
throw new Error(`Model file not found at 'modelPath': ${options.modelPath}`);
|
|
297
|
+
}
|
|
298
|
+
modelInfo = cache.requireInstalledModel(expanded, options.mmprojPath || null);
|
|
299
|
+
} else {
|
|
300
|
+
const modelId = options.modelId || 'smolvlm-500m-q4';
|
|
301
|
+
if (!modelId || !String(modelId).trim()) {
|
|
302
|
+
throw new Error("Parameter 'modelId' cannot be null or empty.");
|
|
303
|
+
}
|
|
304
|
+
if (options.allowDownload && !cache.isModelInstalled(modelId)) {
|
|
305
|
+
await cache.install(modelId);
|
|
306
|
+
}
|
|
307
|
+
modelInfo = cache.requireInstalledModel(modelId, options.mmprojPath || null);
|
|
308
|
+
}
|
|
309
|
+
const executable = resolveLlamaCli(options.runtimePath || null);
|
|
310
|
+
|
|
311
|
+
const reqDevice = (options.device || 'auto').toLowerCase().trim();
|
|
312
|
+
let actualBackend = 'cpu';
|
|
313
|
+
let actualFallback = false;
|
|
314
|
+
|
|
315
|
+
if (reqDevice === 'auto') {
|
|
316
|
+
actualBackend = 'auto';
|
|
317
|
+
actualFallback = true;
|
|
318
|
+
} else if (['vulkan', 'gpu', 'vulkan-force'].includes(reqDevice)) {
|
|
319
|
+
actualBackend = 'vulkan';
|
|
320
|
+
actualFallback = false;
|
|
321
|
+
} else {
|
|
322
|
+
actualBackend = 'cpu';
|
|
323
|
+
actualFallback = false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let threads = 4;
|
|
327
|
+
if (typeof options.threads === 'number') {
|
|
328
|
+
if (options.threads <= 0) throw new Error(`Parameter 'threads' must be > 0. Received: ${options.threads}`);
|
|
329
|
+
threads = Math.max(1, Math.min(128, options.threads));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return new NodeVLMContext({
|
|
333
|
+
manifest: modelInfo.manifest,
|
|
334
|
+
textModelPath: modelInfo.textModelPath,
|
|
335
|
+
visionModelPath: modelInfo.visionModelPath,
|
|
336
|
+
executable: executable,
|
|
337
|
+
threads: threads,
|
|
338
|
+
backend: actualBackend,
|
|
339
|
+
fallback: actualFallback,
|
|
340
|
+
contextLimit: options.contextLimit,
|
|
341
|
+
ngl: options.ngl
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = {
|
|
346
|
+
load,
|
|
347
|
+
resolveLlamaCli,
|
|
348
|
+
NodeVLMContext
|
|
349
|
+
};
|