@qvac/vla-ggml
Advanced tools
+42
| export declare const DEFAULT_IMAGE_SIZE = 512; | ||
| export interface PreprocessImageOptions { | ||
| size?: number; | ||
| layout?: "hwc" | "chw"; | ||
| /** | ||
| * Pixel-range hint that skips the [0,255] vs [0,1] auto-detection | ||
| * heuristic. Pass `1` if pixels are already in [0,1] (no rescale), | ||
| * pass `1/255` if pixels are in [0,255] (rescale to [0,1]). Anything | ||
| * else (including the literal `'auto'`) falls back to the heuristic. | ||
| * Typed as `number | 'auto'` because TS literal types can't represent | ||
| * `1/255` directly. | ||
| */ | ||
| scale?: number | "auto"; | ||
| } | ||
| /** | ||
| * Resize + letterbox-pad an HWC or CHW image to `size × size`, then normalize | ||
| * from [0, 1] (or [0, 255]) to [-1, 1]. Output is a contiguous CHW Float32Array | ||
| * of length 3 * size * size — the format smolvla.cpp's SigLIP encoder expects. | ||
| * | ||
| * Matches the reference implementation in smolvla_ggml.py#preprocess_image: | ||
| * ratio = max(w/size, h/size), resize with bilinear, left/top pad with 0, | ||
| * scale to [-1, 1]. The pad region is filled with -1 (= 0 shifted into [-1, 1]). | ||
| * | ||
| * Bilinear resize, letterbox-pad and the [0,1]→[-1,1] shift run as a single | ||
| * pass over the output buffer; no `src` / `resized` intermediates are allocated | ||
| * (each call needs only the final 3*size*size Float32Array). Per-output-pixel | ||
| * coordinates are computed once and shared across the three channels. | ||
| * | ||
| * @param pixels - source pixels | ||
| * @param width - source width | ||
| * @param height - source height | ||
| * @param opts | ||
| * `scale` skips the [0,255] vs [0,1] heuristic when the caller knows the | ||
| * range (`1` = pixels already in [0,1], `1/255` = pixels in [0,255]). | ||
| */ | ||
| export declare function preprocessImage(pixels: Float32Array | Uint8Array | number[], width: number, height: number, opts?: PreprocessImageOptions): Float32Array; | ||
| /** | ||
| * Zero-pad a state vector to `targetDim`. Extra entries are zero-initialised; | ||
| * input longer than `targetDim` raises. Mirrors how smolvla.cpp expects the | ||
| * state tensor (`max_state_dim` = 32 by default). | ||
| */ | ||
| export declare function padState(state: ArrayLike<number>, targetDim?: number): Float32Array; |
| import QvacError = require("@qvac/error"); | ||
| declare const QvacErrorBase: typeof QvacError.QvacErrorBase; | ||
| export declare class QvacErrorAddonVla extends QvacErrorBase { | ||
| } | ||
| export declare const ERR_CODES: Readonly<{ | ||
| FAILED_TO_LOAD_WEIGHTS: 30001; | ||
| FAILED_TO_DESTROY: 30002; | ||
| MODEL_NOT_FOUND: 30003; | ||
| INVALID_CONFIG: 30004; | ||
| MISSING_REQUIRED_PARAMETER: 30005; | ||
| INVALID_INPUT: 30006; | ||
| JOB_ALREADY_RUNNING: 30007; | ||
| INSTANCE_NOT_INITIALIZED: 30008; | ||
| MODEL_UNLOADED: 30009; | ||
| INFERENCE_FAILED: 30010; | ||
| }>; | ||
| export {}; |
+104
-116
@@ -1,9 +0,10 @@ | ||
| 'use strict' | ||
| "use strict"; | ||
| // Pure-JS helpers for SmolVLA preprocessing. No dependency on the native | ||
| // `.bare` addon, so CI's `ts-checks` job (which runs `test:unit --if-present` | ||
| // without a native build) can exercise these without ADDON_NOT_FOUND failures. | ||
| const DEFAULT_IMAGE_SIZE = 512 | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.DEFAULT_IMAGE_SIZE = void 0; | ||
| exports.preprocessImage = preprocessImage; | ||
| exports.padState = padState; | ||
| exports.DEFAULT_IMAGE_SIZE = 512; | ||
| /** | ||
@@ -23,95 +24,86 @@ * Resize + letterbox-pad an HWC or CHW image to `size × size`, then normalize | ||
| * | ||
| * @param {Float32Array|Uint8Array|number[]} pixels - source pixels | ||
| * @param {number} width - source width | ||
| * @param {number} height - source height | ||
| * @param {{ size?: number, layout?: 'hwc'|'chw', scale?: 1|(1/255)|'auto' }} [opts] | ||
| * @param pixels - source pixels | ||
| * @param width - source width | ||
| * @param height - source height | ||
| * @param opts | ||
| * `scale` skips the [0,255] vs [0,1] heuristic when the caller knows the | ||
| * range (`1` = pixels already in [0,1], `1/255` = pixels in [0,255]). | ||
| * @returns {Float32Array} | ||
| */ | ||
| function preprocessImage(pixels, width, height, opts = {}) { | ||
| const size = opts.size ?? DEFAULT_IMAGE_SIZE | ||
| const layout = opts.layout ?? 'hwc' | ||
| if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { | ||
| throw new TypeError('preprocessImage: width/height must be positive integers') | ||
| } | ||
| const expected = width * height * 3 | ||
| if (pixels.length !== expected) { | ||
| throw new RangeError(`preprocessImage: expected ${expected} pixel values, got ${pixels.length}`) | ||
| } | ||
| const normalize = opts.scale === 1 || opts.scale === 1 / 255 ? opts.scale : detectScale(pixels) | ||
| // Letterbox target size (aspect-ratio preserving). | ||
| const ratio = Math.max(width / size, height / size) | ||
| const newW = Math.max(1, Math.floor(width / ratio)) | ||
| const newH = Math.max(1, Math.floor(height / ratio)) | ||
| const padLeft = size - newW | ||
| const padTop = size - newH | ||
| const xScale = width / newW | ||
| const yScale = height / newH | ||
| // Output starts at -1 so the pad region is already in [-1, 1] and we only | ||
| // need to overwrite the (newH × newW) inner region with the resized content. | ||
| const out = new Float32Array(3 * size * size) | ||
| out.fill(-1) | ||
| const planeStride = size * size | ||
| const widthHeight = width * height | ||
| for (let yy = 0; yy < newH; yy++) { | ||
| const yIn = (yy + 0.5) * yScale - 0.5 | ||
| const y0 = Math.max(0, Math.floor(yIn)) | ||
| const y1 = Math.min(height - 1, y0 + 1) | ||
| const dy = Math.min(1, Math.max(0, yIn - y0)) | ||
| const dyInv = 1 - dy | ||
| const outY = yy + padTop | ||
| for (let xx = 0; xx < newW; xx++) { | ||
| const xIn = (xx + 0.5) * xScale - 0.5 | ||
| const x0 = Math.max(0, Math.floor(xIn)) | ||
| const x1 = Math.min(width - 1, x0 + 1) | ||
| const dx = Math.min(1, Math.max(0, xIn - x0)) | ||
| const dxInv = 1 - dx | ||
| const outX = xx + padLeft | ||
| const w00 = dxInv * dyInv | ||
| const w10 = dx * dyInv | ||
| const w01 = dxInv * dy | ||
| const w11 = dx * dy | ||
| const outIdx = outY * size + outX | ||
| if (layout === 'hwc') { | ||
| const i00 = (y0 * width + x0) * 3 | ||
| const i10 = (y0 * width + x1) * 3 | ||
| const i01 = (y1 * width + x0) * 3 | ||
| const i11 = (y1 * width + x1) * 3 | ||
| for (let c = 0; c < 3; c++) { | ||
| const v = | ||
| pixels[i00 + c] * w00 + | ||
| pixels[i10 + c] * w10 + | ||
| pixels[i01 + c] * w01 + | ||
| pixels[i11 + c] * w11 | ||
| // Apply scale and shift into [-1, 1] in one fused multiply-add. | ||
| out[c * planeStride + outIdx] = v * normalize * 2 - 1 | ||
| const size = opts.size ?? exports.DEFAULT_IMAGE_SIZE; | ||
| const layout = opts.layout ?? "hwc"; | ||
| if (!Number.isInteger(width) || | ||
| !Number.isInteger(height) || | ||
| width <= 0 || | ||
| height <= 0) { | ||
| throw new TypeError("preprocessImage: width/height must be positive integers"); | ||
| } | ||
| const expected = width * height * 3; | ||
| if (pixels.length !== expected) { | ||
| throw new RangeError(`preprocessImage: expected ${expected} pixel values, got ${pixels.length}`); | ||
| } | ||
| const normalize = opts.scale === 1 || opts.scale === 1 / 255 | ||
| ? opts.scale | ||
| : detectScale(pixels); | ||
| // Letterbox target size (aspect-ratio preserving). | ||
| const ratio = Math.max(width / size, height / size); | ||
| const newW = Math.max(1, Math.floor(width / ratio)); | ||
| const newH = Math.max(1, Math.floor(height / ratio)); | ||
| const padLeft = size - newW; | ||
| const padTop = size - newH; | ||
| const xScale = width / newW; | ||
| const yScale = height / newH; | ||
| // Output starts at -1 so the pad region is already in [-1, 1] and we only | ||
| // need to overwrite the (newH × newW) inner region with the resized content. | ||
| const out = new Float32Array(3 * size * size); | ||
| out.fill(-1); | ||
| const planeStride = size * size; | ||
| const widthHeight = width * height; | ||
| for (let yy = 0; yy < newH; yy++) { | ||
| const yIn = (yy + 0.5) * yScale - 0.5; | ||
| const y0 = Math.max(0, Math.floor(yIn)); | ||
| const y1 = Math.min(height - 1, y0 + 1); | ||
| const dy = Math.min(1, Math.max(0, yIn - y0)); | ||
| const dyInv = 1 - dy; | ||
| const outY = yy + padTop; | ||
| for (let xx = 0; xx < newW; xx++) { | ||
| const xIn = (xx + 0.5) * xScale - 0.5; | ||
| const x0 = Math.max(0, Math.floor(xIn)); | ||
| const x1 = Math.min(width - 1, x0 + 1); | ||
| const dx = Math.min(1, Math.max(0, xIn - x0)); | ||
| const dxInv = 1 - dx; | ||
| const outX = xx + padLeft; | ||
| const w00 = dxInv * dyInv; | ||
| const w10 = dx * dyInv; | ||
| const w01 = dxInv * dy; | ||
| const w11 = dx * dy; | ||
| const outIdx = outY * size + outX; | ||
| if (layout === "hwc") { | ||
| const i00 = (y0 * width + x0) * 3; | ||
| const i10 = (y0 * width + x1) * 3; | ||
| const i01 = (y1 * width + x0) * 3; | ||
| const i11 = (y1 * width + x1) * 3; | ||
| for (let c = 0; c < 3; c++) { | ||
| const v = pixels[i00 + c] * w00 + | ||
| pixels[i10 + c] * w10 + | ||
| pixels[i01 + c] * w01 + | ||
| pixels[i11 + c] * w11; | ||
| // Apply scale and shift into [-1, 1] in one fused multiply-add. | ||
| out[c * planeStride + outIdx] = v * normalize * 2 - 1; | ||
| } | ||
| } | ||
| else { | ||
| for (let c = 0; c < 3; c++) { | ||
| const plane = c * widthHeight; | ||
| const v = pixels[plane + y0 * width + x0] * w00 + | ||
| pixels[plane + y0 * width + x1] * w10 + | ||
| pixels[plane + y1 * width + x0] * w01 + | ||
| pixels[plane + y1 * width + x1] * w11; | ||
| out[c * planeStride + outIdx] = v * normalize * 2 - 1; | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| for (let c = 0; c < 3; c++) { | ||
| const plane = c * widthHeight | ||
| const v = | ||
| pixels[plane + y0 * width + x0] * w00 + | ||
| pixels[plane + y0 * width + x1] * w10 + | ||
| pixels[plane + y1 * width + x0] * w01 + | ||
| pixels[plane + y1 * width + x1] * w11 | ||
| out[c * planeStride + outIdx] = v * normalize * 2 - 1 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return out | ||
| return out; | ||
| } | ||
| /** | ||
@@ -121,31 +113,27 @@ * Zero-pad a state vector to `targetDim`. Extra entries are zero-initialised; | ||
| * state tensor (`max_state_dim` = 32 by default). | ||
| * | ||
| * @param {ArrayLike<number>} state | ||
| * @param {number} targetDim | ||
| * @returns {Float32Array} | ||
| */ | ||
| function padState(state, targetDim = 32) { | ||
| if (!Number.isInteger(targetDim) || targetDim <= 0) { | ||
| throw new TypeError('padState: targetDim must be a positive integer') | ||
| } | ||
| if (state.length > targetDim) { | ||
| throw new RangeError(`padState: input length ${state.length} exceeds targetDim ${targetDim}`) | ||
| } | ||
| const out = new Float32Array(targetDim) | ||
| for (let i = 0; i < state.length; i++) out[i] = state[i] | ||
| return out | ||
| if (!Number.isInteger(targetDim) || targetDim <= 0) { | ||
| throw new TypeError("padState: targetDim must be a positive integer"); | ||
| } | ||
| if (state.length > targetDim) { | ||
| throw new RangeError(`padState: input length ${state.length} exceeds targetDim ${targetDim}`); | ||
| } | ||
| const out = new Float32Array(targetDim); | ||
| for (let i = 0; i < state.length; i++) | ||
| out[i] = state[i]; | ||
| return out; | ||
| } | ||
| function detectScale(pixels) { | ||
| if (pixels instanceof Uint8Array) return 1 / 255 | ||
| // Float/Number arrays: scan a small window to decide whether it's [0,255] or [0,1]. | ||
| const limit = Math.min(pixels.length, 256) | ||
| let maxVal = 0 | ||
| for (let i = 0; i < limit; i++) { | ||
| const v = pixels[i] | ||
| if (v > maxVal) maxVal = v | ||
| } | ||
| return maxVal > 1.001 ? 1 / 255 : 1 | ||
| if (pixels instanceof Uint8Array) | ||
| return 1 / 255; | ||
| // Float/Number arrays: scan a small window to decide whether it's [0,255] or [0,1]. | ||
| const limit = Math.min(pixels.length, 256); | ||
| let maxVal = 0; | ||
| for (let i = 0; i < limit; i++) { | ||
| const v = pixels[i]; | ||
| if (v > maxVal) | ||
| maxVal = v; | ||
| } | ||
| return maxVal > 1.001 ? 1 / 255 : 1; | ||
| } | ||
| module.exports = { preprocessImage, padState, DEFAULT_IMAGE_SIZE } |
+142
-124
@@ -1,128 +0,146 @@ | ||
| export const DEFAULT_IMAGE_SIZE: number | ||
| export class QvacErrorAddonVla extends Error { | ||
| code: number | ||
| import QvacLogger = require("@qvac/logging"); | ||
| import addonModule = require("./addon"); | ||
| import errorModule = require("./lib/error"); | ||
| interface VlaConfig { | ||
| verbosity?: number; | ||
| backendsDir?: string; | ||
| [key: string]: unknown; | ||
| } | ||
| export const ERR_CODES: Readonly<{ | ||
| FAILED_TO_LOAD_WEIGHTS: 30001 | ||
| FAILED_TO_DESTROY: 30002 | ||
| MODEL_NOT_FOUND: 30003 | ||
| INVALID_CONFIG: 30004 | ||
| MISSING_REQUIRED_PARAMETER: 30005 | ||
| INVALID_INPUT: 30006 | ||
| JOB_ALREADY_RUNNING: 30007 | ||
| INSTANCE_NOT_INITIALIZED: 30008 | ||
| MODEL_UNLOADED: 30009 | ||
| INFERENCE_FAILED: 30010 | ||
| }> | ||
| export interface VlaHparams { | ||
| chunkSize: number | ||
| actionDim: number | ||
| maxActionDim: number | ||
| maxStateDim: number | ||
| tokenizerMaxLength: number | ||
| visionImageSize: number | ||
| /** | ||
| * Number of camera views the model accepts. 2 for SmolVLA, up to 3 for | ||
| * π₀.₅. Optional for back-compat — older addon builds may omit it. | ||
| */ | ||
| numCameras?: number | ||
| /** | ||
| * How the consumer passes the robot state. `'continuous'` (SmolVLA) means | ||
| * the `state` Float32Array is projected by an in-model linear layer; | ||
| * `'discrete'` (π₀.₅) means the state is already tokenized into the | ||
| * language prompt and the `state` buffer is ignored. Optional for | ||
| * back-compat. | ||
| */ | ||
| stateInputMode?: 'continuous' | 'discrete' | ||
| interface VlaModelState { | ||
| configLoaded: boolean; | ||
| weightsLoaded: boolean; | ||
| } | ||
| export interface VlaRunInput { | ||
| images: Float32Array[] | ||
| imgWidth?: number | ||
| imgHeight?: number | ||
| state: Float32Array | ||
| tokens: Int32Array | ||
| mask: Uint8Array | ||
| noise?: Float32Array | null | ||
| declare class VlaModel { | ||
| readonly logger: QvacLogger; | ||
| opts: { | ||
| stats?: boolean; | ||
| }; | ||
| state: VlaModelState; | ||
| private _files; | ||
| private _config; | ||
| private _job; | ||
| private _run; | ||
| private _handle; | ||
| private _hparams; | ||
| private _backendName; | ||
| private _hasActiveResponse; | ||
| private _nativeLoggerActive; | ||
| private _packageName; | ||
| private _packageVersion; | ||
| private _pending; | ||
| constructor(options: VlaModel.VlaModelOptions); | ||
| private _connectNativeLogger; | ||
| private _onAddonEvent; | ||
| private _releaseNativeLogger; | ||
| load({ backend }?: { | ||
| backend?: "auto" | "cpu"; | ||
| }): Promise<void>; | ||
| private _load; | ||
| get hparams(): VlaModel.VlaHparams | null; | ||
| get backendName(): string | null; | ||
| run(input: VlaModel.VlaRunInput): Promise<VlaModel.QvacResponse>; | ||
| private _runInternal; | ||
| pause(): Promise<void>; | ||
| cancel(): Promise<void>; | ||
| unload(): Promise<void>; | ||
| getState(): VlaModelState; | ||
| } | ||
| export interface VlaRunStats { | ||
| vision_ms: number | ||
| /** | ||
| * SmolVLA-specific legacy alias for `prefill_compute_ms`. Kept for | ||
| * back-compat with consumers written against v0.1.x; will be removed once | ||
| * π₀.₅ ships and consumers migrate to the architecture-neutral names. | ||
| */ | ||
| smollm2_compute_ms: number | ||
| /** Legacy alias for `prefill_total_ms`; see `smollm2_compute_ms`. */ | ||
| smollm2_total_ms: number | ||
| /** Architecture-neutral prefill compute time (ms). */ | ||
| prefill_compute_ms: number | ||
| /** Architecture-neutral prefill total time (ms). */ | ||
| prefill_total_ms: number | ||
| ode_ms: number | ||
| total_ms: number | ||
| /** 0 = CPU backend, 1 = GPU backend (Vulkan / Metal / OpenCL). */ | ||
| backendDevice: number | ||
| import VlaModelClass = VlaModel; | ||
| /** | ||
| * Declaration merging with the class above models this package's CommonJS | ||
| * export shape — `module.exports` IS the `VlaModel` constructor, carrying the | ||
| * named exports as own properties — directly in the type system. TypeScript | ||
| * emits the property attachments natively, so the generated `index.js` and | ||
| * `index.d.ts` can no longer drift from each other, and a CommonJS consumer | ||
| * (`import VlaModel = require('@qvac/vla-ggml')`) gets a real construct | ||
| * signature instead of TS2351. | ||
| */ | ||
| declare namespace VlaModel { | ||
| export import VlaModel = VlaModelClass; | ||
| export import preprocessImage = addonModule.preprocessImage; | ||
| export import padState = addonModule.padState; | ||
| export import DEFAULT_IMAGE_SIZE = addonModule.DEFAULT_IMAGE_SIZE; | ||
| export import QvacErrorAddonVla = errorModule.QvacErrorAddonVla; | ||
| export import ERR_CODES = errorModule.ERR_CODES; | ||
| interface VlaHparams { | ||
| chunkSize: number; | ||
| actionDim: number; | ||
| maxActionDim: number; | ||
| maxStateDim: number; | ||
| tokenizerMaxLength: number; | ||
| visionImageSize: number; | ||
| /** | ||
| * Number of camera views the model accepts. 2 for SmolVLA, up to 3 for | ||
| * π₀.₅. Optional for back-compat — older addon builds may omit it. | ||
| */ | ||
| numCameras?: number; | ||
| /** | ||
| * How the consumer passes the robot state. `'continuous'` (SmolVLA) means | ||
| * the `state` Float32Array is projected by an in-model linear layer; | ||
| * `'discrete'` (π₀.₅) means the state is already tokenized into the | ||
| * language prompt and the `state` buffer is ignored. Optional for | ||
| * back-compat. | ||
| */ | ||
| stateInputMode?: "continuous" | "discrete"; | ||
| /** | ||
| * How the consumer passes camera images. `'pixels'` (SmolVLA, π₀.₅) means | ||
| * each image is a `3 · w · h` float pixel plane; `'patches'` (GR00T) means | ||
| * each image is already patchified by Gr00tPolicy into a | ||
| * `patches · patch_flat` buffer. Optional for back-compat. | ||
| */ | ||
| imageInputMode?: "pixels" | "patches"; | ||
| /** | ||
| * Exact per-image buffer length (in floats) required when | ||
| * `imageInputMode === 'patches'`. Optional for back-compat. | ||
| */ | ||
| imagePatchElems?: number; | ||
| } | ||
| interface VlaRunInput { | ||
| images: Float32Array[]; | ||
| imgWidth?: number; | ||
| imgHeight?: number; | ||
| state: Float32Array; | ||
| tokens: Int32Array; | ||
| mask: Uint8Array; | ||
| noise?: Float32Array | null; | ||
| } | ||
| interface VlaRunStats { | ||
| vision_ms: number; | ||
| /** | ||
| * SmolVLA-specific legacy alias for `prefill_compute_ms`. Kept for | ||
| * back-compat with consumers written against v0.1.x; will be removed once | ||
| * π₀.₅ ships and consumers migrate to the architecture-neutral names. | ||
| */ | ||
| smollm2_compute_ms: number; | ||
| /** Legacy alias for `prefill_total_ms`; see `smollm2_compute_ms`. */ | ||
| smollm2_total_ms: number; | ||
| /** Architecture-neutral prefill compute time (ms). */ | ||
| prefill_compute_ms: number; | ||
| /** Architecture-neutral prefill total time (ms). */ | ||
| prefill_total_ms: number; | ||
| ode_ms: number; | ||
| total_ms: number; | ||
| /** 0 = CPU backend, 1 = GPU backend (Vulkan / Metal / OpenCL). */ | ||
| backendDevice: number; | ||
| } | ||
| interface VlaRunResult { | ||
| actions: Float32Array; | ||
| stats: VlaRunStats; | ||
| } | ||
| interface VlaModelOptions { | ||
| files: { | ||
| model: string[]; | ||
| }; | ||
| config?: VlaConfig; | ||
| logger?: QvacLogger.LoggerInterface | null; | ||
| opts?: { | ||
| stats?: boolean; | ||
| }; | ||
| } | ||
| interface QvacResponse { | ||
| await(): Promise<VlaRunResult>; | ||
| cancel(): Promise<void>; | ||
| on(event: string, listener: (...args: unknown[]) => void): this; | ||
| } | ||
| } | ||
| export interface VlaRunResult { | ||
| actions: Float32Array | ||
| stats: VlaRunStats | ||
| } | ||
| export interface VlaModelOptions { | ||
| files: { model: string[] } | ||
| config?: Record<string, unknown> | ||
| logger?: unknown | ||
| opts?: { stats?: boolean } | ||
| } | ||
| export interface QvacResponse { | ||
| await(): Promise<VlaRunResult> | ||
| cancel(): Promise<void> | ||
| on(event: string, listener: (...args: unknown[]) => void): this | ||
| } | ||
| export class VlaModel { | ||
| constructor (options: VlaModelOptions) | ||
| readonly hparams: VlaHparams | null | ||
| /** | ||
| * Name of the ggml backend the loaded model is running on | ||
| * ("CPU" / "Vulkan" / "OpenCL" / "Metal"). `null` before `load()`. | ||
| */ | ||
| readonly backendName: string | null | ||
| load (opts?: { backend?: 'auto' | 'cpu' }): Promise<void> | ||
| run (input: VlaRunInput): Promise<QvacResponse> | ||
| pause (): Promise<void> | ||
| cancel (): Promise<void> | ||
| unload (): Promise<void> | ||
| getState (): { configLoaded: boolean; weightsLoaded: boolean } | ||
| } | ||
| export function preprocessImage ( | ||
| pixels: Float32Array | Uint8Array | number[], | ||
| width: number, | ||
| height: number, | ||
| opts?: { | ||
| size?: number, | ||
| layout?: 'hwc' | 'chw', | ||
| /** | ||
| * Pixel-range hint that skips the [0,255] vs [0,1] auto-detection | ||
| * heuristic. Pass `1` if pixels are already in [0,1] (no rescale), | ||
| * pass `1/255` if pixels are in [0,255] (rescale to [0,1]). Anything | ||
| * else (including the literal `'auto'`) falls back to the heuristic. | ||
| * Typed as `number | 'auto'` because TS literal types can't represent | ||
| * `1/255` directly. | ||
| */ | ||
| scale?: number | 'auto' | ||
| } | ||
| ): Float32Array | ||
| export function padState (state: ArrayLike<number>, targetDim?: number): Float32Array | ||
| declare const _default: typeof VlaModel | ||
| export default _default | ||
| export = VlaModel; |
+540
-493
@@ -1,534 +0,581 @@ | ||
| 'use strict' | ||
| const fs = require('bare-fs') | ||
| const path = require('bare-path') | ||
| const QvacLogger = require('@qvac/logging') | ||
| const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base') | ||
| const binding = require('./binding') | ||
| const { preprocessImage, padState, DEFAULT_IMAGE_SIZE } = require('./addon.js') | ||
| const { QvacErrorAddonVla, ERR_CODES } = require('./lib/error') | ||
| "use strict"; | ||
| /* eslint-disable @typescript-eslint/no-require-imports -- Bare modules and @qvac/logging expose CommonJS export shapes. */ | ||
| const fs = require("bare-fs"); | ||
| const path = require("bare-path"); | ||
| const QvacLogger = require("@qvac/logging"); | ||
| // `./addon` and `./lib/error` are imported as whole-module aliases rather than | ||
| // named imports so the `VlaModel` namespace at the bottom of this file can | ||
| // re-export their members with `export import`. That is what makes the | ||
| // CommonJS "class object with attached properties" export shape part of the | ||
| // emitted declarations instead of an untyped `module.exports` cast. | ||
| const addonModule = require("./addon"); | ||
| const errorModule = require("./lib/error"); | ||
| /* eslint-enable @typescript-eslint/no-require-imports */ | ||
| const infer_base_1 = require("@qvac/infer-base"); | ||
| const { DEFAULT_IMAGE_SIZE } = addonModule; | ||
| const { QvacErrorAddonVla, ERR_CODES } = errorModule; | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports -- native binding is resolved lazily from package prebuilds. | ||
| const binding = require("./binding"); | ||
| // Maps the C++ Priority enum (0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG) to the | ||
| // matching method on the JS QvacLogger instance. Mirrors diffusion-cpp. | ||
| const LOG_METHODS = ['error', 'warn', 'info', 'debug'] | ||
| const LOG_METHODS = ["error", "warn", "info", "debug"]; | ||
| // Default verbosity sent to the C++ side when a logger is connected. Matches | ||
| // the JS-side QvacLogger default — INFO and above are forwarded, DEBUG drops | ||
| // unless explicitly raised. | ||
| const DEFAULT_NATIVE_VERBOSITY = 2 // INFO | ||
| const DEFAULT_NATIVE_VERBOSITY = 2; // INFO | ||
| function pickPrimaryGgufPath(files) { | ||
| const FIRST_SHARD_REGEX = /-0*1-of-\d+\.gguf$/ | ||
| return files.find((p) => FIRST_SHARD_REGEX.test(p)) || files[0] | ||
| const FIRST_SHARD_REGEX = /-0*1-of-\d+\.gguf$/; | ||
| return files.find((p) => FIRST_SHARD_REGEX.test(p)) || files[0]; | ||
| } | ||
| function validateRunInput(input, hparams) { | ||
| if (!input || typeof input !== 'object') { | ||
| throw new QvacErrorAddonVla({ code: ERR_CODES.INVALID_INPUT, adds: 'input must be an object' }) | ||
| } | ||
| if (!Array.isArray(input.images) || input.images.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.images must be a non-empty array of Float32Array' | ||
| }) | ||
| } | ||
| const imgWidth = input.imgWidth ?? DEFAULT_IMAGE_SIZE | ||
| const imgHeight = input.imgHeight ?? DEFAULT_IMAGE_SIZE | ||
| // The C++ inference path requires img_width == img_height == hparams.visionImageSize | ||
| // (SigLIP's conv2d output is sized from runtime args, but the downstream | ||
| // patch-embedding reshape uses hp.vision_image_size — a mismatch trips | ||
| // GGML_ASSERT in ggml.c, which is a hard abort that kills the worker). | ||
| // Throw a clean QvacError here so the failure surfaces as a rejected | ||
| // run() promise instead of a process crash. | ||
| if (hparams && Number.isInteger(hparams.visionImageSize)) { | ||
| const expected = hparams.visionImageSize | ||
| if (imgWidth !== expected || imgHeight !== expected) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `imgWidth/imgHeight (${imgWidth}x${imgHeight}) must equal hparams.visionImageSize (${expected})` | ||
| }) | ||
| if (!input || typeof input !== "object") { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input must be an object", | ||
| }); | ||
| } | ||
| } | ||
| // Pixel-plane models (smolvla, pi05) take `3 · w · h` floats per camera. | ||
| // GR00T takes images already patchified by Gr00tPolicy — a | ||
| // `patches · patch_flat` buffer whose exact length the native side copies | ||
| // blindly (no source-length check before the memcpy), so it MUST be validated | ||
| // here. The length is fixed per model (imgWidth is pinned to visionImageSize), | ||
| // surfaced as `hparams.imagePatchElems`. `imageInputMode` is the | ||
| // distinguishing axis (both groot and smolvla are `continuous` state). | ||
| const imagesArePatches = hparams && hparams.imageInputMode === 'patches' | ||
| const patchElems = imagesArePatches ? hparams.imagePatchElems : 0 | ||
| const patchElemsKnown = Number.isInteger(patchElems) && patchElems > 0 | ||
| const expectedPerImage = 3 * imgWidth * imgHeight | ||
| for (let i = 0; i < input.images.length; i++) { | ||
| const img = input.images[i] | ||
| if (!(img instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] must be a Float32Array` | ||
| }) | ||
| } | ||
| if (imagesArePatches) { | ||
| // Exact length guards the native memcpy against an OOB read. The native | ||
| // side copies patchElems floats per image blindly (no source-length | ||
| // check), so the expected length MUST be known here; if the addon didn't | ||
| // surface imagePatchElems (version skew), fail closed rather than pass an | ||
| // unvalidated buffer to that memcpy. | ||
| if (!patchElemsKnown) { | ||
| if (!Array.isArray(input.images) || input.images.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] (patches): addon did not surface hparams.imagePatchElems, cannot validate patch buffer length` | ||
| }) | ||
| } | ||
| if (img.length !== patchElems) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] (patches) length ${img.length} != ${patchElems}` | ||
| }) | ||
| } | ||
| } else if (img.length !== expectedPerImage) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] length ${img.length} != 3*${imgWidth}*${imgHeight}` | ||
| }) | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.images must be a non-empty array of Float32Array", | ||
| }); | ||
| } | ||
| } | ||
| if (!(input.state instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.state must be a Float32Array' | ||
| }) | ||
| } | ||
| if (!(input.tokens instanceof Int32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.tokens must be an Int32Array' | ||
| }) | ||
| } | ||
| if (!(input.mask instanceof Uint8Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.mask must be a Uint8Array' | ||
| }) | ||
| } | ||
| if (input.mask.length !== input.tokens.length) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.mask and input.tokens must have the same length' | ||
| }) | ||
| } | ||
| if (input.noise !== undefined && input.noise !== null && !(input.noise instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'input.noise must be a Float32Array when provided' | ||
| }) | ||
| } | ||
| if (hparams && hparams.stateInputMode === 'continuous' && Number.isInteger(hparams.maxStateDim)) { | ||
| if (input.state.length === 0 || input.state.length > hparams.maxStateDim) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `state.length (${input.state.length}) must be > 0 and <= hparams.maxStateDim (${hparams.maxStateDim})` | ||
| }) | ||
| const imgWidth = input.imgWidth ?? DEFAULT_IMAGE_SIZE; | ||
| const imgHeight = input.imgHeight ?? DEFAULT_IMAGE_SIZE; | ||
| // The C++ inference path requires img_width == img_height == hparams.visionImageSize | ||
| // (SigLIP's conv2d output is sized from runtime args, but the downstream | ||
| // patch-embedding reshape uses hp.vision_image_size — a mismatch trips | ||
| // GGML_ASSERT in ggml.c, which is a hard abort that kills the worker). | ||
| // Throw a clean QvacError here so the failure surfaces as a rejected | ||
| // run() promise instead of a process crash. | ||
| if (hparams && Number.isInteger(hparams.visionImageSize)) { | ||
| const expected = hparams.visionImageSize; | ||
| if (imgWidth !== expected || imgHeight !== expected) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `imgWidth/imgHeight (${imgWidth}x${imgHeight}) must equal hparams.visionImageSize (${expected})`, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| // GR00T (imageInputMode 'patches') is a continuous-state flow-matching model | ||
| // that does NOT sample noise internally — GrootModel::infer hard-rejects a | ||
| // null noise. The discrete branch below already enforces this for pi05; do the | ||
| // same for the continuous/patches path so a missing prior surfaces as a clean | ||
| // INVALID_INPUT rather than an opaque INFERENCE_FAILED from the worker. | ||
| if (hparams && hparams.imageInputMode === 'patches') { | ||
| // Fail closed on GR00T's fixed-shape embodiment contract before the noise | ||
| // checks. GrootModel::infer derives the image-placeholder count from | ||
| // nImages and accepts nImages >= 1, so a one-camera input against a | ||
| // two-camera checkpoint would silently produce actions for the wrong camera | ||
| // layout instead of a clean INVALID_INPUT. The shared continuous-state | ||
| // check above allows state.length <= maxStateDim; GR00T needs it exact. | ||
| if (Number.isInteger(hparams.numCameras) && input.images.length !== hparams.numCameras) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `groot requires exactly ${hparams.numCameras} patch image buffers (got ${input.images.length})` | ||
| }) | ||
| // Pixel-plane models (smolvla, pi05) take `3 · w · h` floats per camera. | ||
| // GR00T takes images already patchified by Gr00tPolicy — a | ||
| // `patches · patch_flat` buffer whose exact length the native side copies | ||
| // blindly (no source-length check before the memcpy), so it MUST be validated | ||
| // here. The length is fixed per model (imgWidth is pinned to visionImageSize), | ||
| // surfaced as `hparams.imagePatchElems`. `imageInputMode` is the | ||
| // distinguishing axis (both groot and smolvla are `continuous` state). | ||
| const imagesArePatches = hparams !== null && hparams.imageInputMode === "patches"; | ||
| const patchElems = imagesArePatches ? (hparams.imagePatchElems ?? 0) : 0; | ||
| const patchElemsKnown = Number.isInteger(patchElems) && patchElems > 0; | ||
| const expectedPerImage = 3 * imgWidth * imgHeight; | ||
| for (let i = 0; i < input.images.length; i++) { | ||
| const img = input.images[i]; | ||
| if (!(img instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] must be a Float32Array`, | ||
| }); | ||
| } | ||
| if (imagesArePatches) { | ||
| // Exact length guards the native memcpy against an OOB read. The native | ||
| // side copies patchElems floats per image blindly (no source-length | ||
| // check), so the expected length MUST be known here; if the addon didn't | ||
| // surface imagePatchElems (version skew), fail closed rather than pass an | ||
| // unvalidated buffer to that memcpy. | ||
| if (!patchElemsKnown) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] (patches): addon did not surface hparams.imagePatchElems, cannot validate patch buffer length`, | ||
| }); | ||
| } | ||
| if (img.length !== patchElems) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] (patches) length ${img.length} != ${patchElems}`, | ||
| }); | ||
| } | ||
| } | ||
| else if (img.length !== expectedPerImage) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.images[${i}] length ${img.length} != 3*${imgWidth}*${imgHeight}`, | ||
| }); | ||
| } | ||
| } | ||
| if (Number.isInteger(hparams.maxStateDim) && input.state.length !== hparams.maxStateDim) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `groot requires state.length === ${hparams.maxStateDim} (got ${input.state.length})` | ||
| }) | ||
| if (!(input.state instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.state must be a Float32Array", | ||
| }); | ||
| } | ||
| if (!input.noise || !(input.noise instanceof Float32Array) || input.noise.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'groot requires input.noise (Float32Array) — flow matching needs a noise prior at t=1' | ||
| }) | ||
| if (!(input.tokens instanceof Int32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.tokens must be an Int32Array", | ||
| }); | ||
| } | ||
| // Exact length guards the native memcpy against an OOB read: GrootModel::infer | ||
| // copies chunkSize*maxActionDim floats blindly from this buffer (no source-length | ||
| // check), so a short array reads adjacent heap memory into the action prior. | ||
| if (Number.isInteger(hparams.chunkSize) && Number.isInteger(hparams.maxActionDim)) { | ||
| const expectedNoise = hparams.chunkSize * hparams.maxActionDim | ||
| if (input.noise.length !== expectedNoise) { | ||
| if (!(input.mask instanceof Uint8Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)` | ||
| }) | ||
| } | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.mask must be a Uint8Array", | ||
| }); | ||
| } | ||
| } | ||
| if (hparams && hparams.stateInputMode === 'discrete') { | ||
| if (Number.isInteger(hparams.numCameras) && input.images.length !== hparams.numCameras) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `pi05 requires exactly ${hparams.numCameras} camera images (got ${input.images.length})` | ||
| }) | ||
| if (input.mask.length !== input.tokens.length) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.mask and input.tokens must have the same length", | ||
| }); | ||
| } | ||
| if ( | ||
| Number.isInteger(hparams.tokenizerMaxLength) && | ||
| input.tokens.length !== hparams.tokenizerMaxLength | ||
| ) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `pi05 requires tokens.length === ${hparams.tokenizerMaxLength} (got ${input.tokens.length})` | ||
| }) | ||
| if (input.noise !== undefined && | ||
| input.noise !== null && | ||
| !(input.noise instanceof Float32Array)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "input.noise must be a Float32Array when provided", | ||
| }); | ||
| } | ||
| if (!input.noise || !(input.noise instanceof Float32Array) || input.noise.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: 'pi05 requires input.noise (Float32Array) — flow matching needs a noise prior at t=1' | ||
| }) | ||
| if (hparams && | ||
| hparams.stateInputMode === "continuous" && | ||
| Number.isInteger(hparams.maxStateDim)) { | ||
| if (input.state.length === 0 || input.state.length > hparams.maxStateDim) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `state.length (${input.state.length}) must be > 0 and <= hparams.maxStateDim (${hparams.maxStateDim})`, | ||
| }); | ||
| } | ||
| } | ||
| // Same native-memcpy OOB guard as the groot branch: pi05 xT is action_horizon * | ||
| // action_dim floats, surfaced as chunkSize * maxActionDim (max_action_dim maps | ||
| // to action_dim for pi05), copied blindly from this buffer. | ||
| if (Number.isInteger(hparams.chunkSize) && Number.isInteger(hparams.maxActionDim)) { | ||
| const expectedNoise = hparams.chunkSize * hparams.maxActionDim | ||
| if (input.noise.length !== expectedNoise) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)` | ||
| }) | ||
| } | ||
| // GR00T (imageInputMode 'patches') is a continuous-state flow-matching model | ||
| // that does NOT sample noise internally — GrootModel::infer hard-rejects a | ||
| // null noise. The discrete branch below already enforces this for pi05; do the | ||
| // same for the continuous/patches path so a missing prior surfaces as a clean | ||
| // INVALID_INPUT rather than an opaque INFERENCE_FAILED from the worker. | ||
| if (hparams && hparams.imageInputMode === "patches") { | ||
| // Fail closed on GR00T's fixed-shape embodiment contract before the noise | ||
| // checks. GrootModel::infer derives the image-placeholder count from | ||
| // nImages and accepts nImages >= 1, so a one-camera input against a | ||
| // two-camera checkpoint would silently produce actions for the wrong camera | ||
| // layout instead of a clean INVALID_INPUT. The shared continuous-state | ||
| // check above allows state.length <= maxStateDim; GR00T needs it exact. | ||
| if (Number.isInteger(hparams.numCameras) && | ||
| input.images.length !== hparams.numCameras) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `groot requires exactly ${hparams.numCameras} patch image buffers (got ${input.images.length})`, | ||
| }); | ||
| } | ||
| if (Number.isInteger(hparams.maxStateDim) && | ||
| input.state.length !== hparams.maxStateDim) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `groot requires state.length === ${hparams.maxStateDim} (got ${input.state.length})`, | ||
| }); | ||
| } | ||
| if (!input.noise || | ||
| !(input.noise instanceof Float32Array) || | ||
| input.noise.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "groot requires input.noise (Float32Array) — flow matching needs a noise prior at t=1", | ||
| }); | ||
| } | ||
| // Exact length guards the native memcpy against an OOB read: GrootModel::infer | ||
| // copies chunkSize*maxActionDim floats blindly from this buffer (no source-length | ||
| // check), so a short array reads adjacent heap memory into the action prior. | ||
| if (Number.isInteger(hparams.chunkSize) && | ||
| Number.isInteger(hparams.maxActionDim)) { | ||
| const expectedNoise = hparams.chunkSize * hparams.maxActionDim; | ||
| if (input.noise.length !== expectedNoise) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { imgWidth, imgHeight } | ||
| if (hparams && hparams.stateInputMode === "discrete") { | ||
| if (Number.isInteger(hparams.numCameras) && | ||
| input.images.length !== hparams.numCameras) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `pi05 requires exactly ${hparams.numCameras} camera images (got ${input.images.length})`, | ||
| }); | ||
| } | ||
| if (Number.isInteger(hparams.tokenizerMaxLength) && | ||
| input.tokens.length !== hparams.tokenizerMaxLength) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `pi05 requires tokens.length === ${hparams.tokenizerMaxLength} (got ${input.tokens.length})`, | ||
| }); | ||
| } | ||
| if (!input.noise || | ||
| !(input.noise instanceof Float32Array) || | ||
| input.noise.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: "pi05 requires input.noise (Float32Array) — flow matching needs a noise prior at t=1", | ||
| }); | ||
| } | ||
| // Same native-memcpy OOB guard as the groot branch: pi05 xT is action_horizon * | ||
| // action_dim floats, surfaced as chunkSize * maxActionDim (max_action_dim maps | ||
| // to action_dim for pi05), copied blindly from this buffer. | ||
| if (Number.isInteger(hparams.chunkSize) && | ||
| Number.isInteger(hparams.maxActionDim)) { | ||
| const expectedNoise = hparams.chunkSize * hparams.maxActionDim; | ||
| if (input.noise.length !== expectedNoise) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_INPUT, | ||
| adds: `input.noise length ${input.noise.length} != ${expectedNoise} (chunkSize*maxActionDim)`, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { imgWidth, imgHeight }; | ||
| } | ||
| class VlaModel { | ||
| constructor({ files, config = {}, logger = null, opts = {} } = {}) { | ||
| if (!files || !Array.isArray(files.model) || files.model.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.MISSING_REQUIRED_PARAMETER, | ||
| adds: 'files.model (non-empty array of absolute paths)' | ||
| }) | ||
| logger; | ||
| opts; | ||
| state; | ||
| _files; | ||
| _config; | ||
| _job; | ||
| _run; | ||
| _handle; | ||
| _hparams; | ||
| _backendName; | ||
| _hasActiveResponse; | ||
| _nativeLoggerActive; | ||
| _packageName; | ||
| _packageVersion; | ||
| // Per-run accumulator filled by _onAddonEvent; null between runs. | ||
| _pending; | ||
| constructor(options) { | ||
| const { files, config = {}, logger = null, opts = {}, } = options ?? { files: { model: [] } }; | ||
| if (!files || !Array.isArray(files.model) || files.model.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.MISSING_REQUIRED_PARAMETER, | ||
| adds: "files.model (non-empty array of absolute paths)", | ||
| }); | ||
| } | ||
| for (const [i, entry] of files.model.entries()) { | ||
| if (typeof entry !== "string" || entry.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `files.model[${i}] must be an absolute path string`, | ||
| }); | ||
| } | ||
| if (!path.isAbsolute(entry)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `files.model[${i}] must be an absolute path (got: ${entry})`, | ||
| }); | ||
| } | ||
| } | ||
| this._files = files.model; | ||
| this._config = config; | ||
| this.logger = new QvacLogger(logger); | ||
| this.opts = opts; | ||
| // The cancel hook is wired to the framework's binding.cancel(handle) | ||
| // through the public cancel() method; the createJobHandler tear-down | ||
| // flows through that path. | ||
| this._job = (0, infer_base_1.createJobHandler)({ cancel: () => this.cancel() }); | ||
| this._run = (0, infer_base_1.exclusiveRunQueue)(); | ||
| this._handle = null; | ||
| this._hparams = null; | ||
| this._backendName = null; | ||
| this._hasActiveResponse = false; | ||
| this._nativeLoggerActive = false; | ||
| this._packageName = "@qvac/vla-ggml"; | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports -- package metadata is read from the package root at runtime. | ||
| this._packageVersion = require("./package.json") | ||
| .version; | ||
| // Per-run accumulator filled by _onAddonEvent; null between runs. | ||
| this._pending = null; | ||
| this.state = { configLoaded: false, weightsLoaded: false }; | ||
| } | ||
| for (const [i, entry] of files.model.entries()) { | ||
| if (typeof entry !== 'string' || entry.length === 0) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `files.model[${i}] must be an absolute path string` | ||
| }) | ||
| } | ||
| if (!path.isAbsolute(entry)) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `files.model[${i}] must be an absolute path (got: ${entry})` | ||
| }) | ||
| } | ||
| _connectNativeLogger() { | ||
| if (this._nativeLoggerActive) | ||
| return; | ||
| try { | ||
| binding.setLogger((priority, message) => { | ||
| const method = LOG_METHODS[priority] || "info"; | ||
| if (typeof this.logger[method] === "function") { | ||
| this.logger[method](`[C++] ${message}`); | ||
| } | ||
| }); | ||
| const verbosity = Number.isInteger(this._config.verbosity) | ||
| ? this._config.verbosity | ||
| : DEFAULT_NATIVE_VERBOSITY; | ||
| try { | ||
| binding.setVerbosity(verbosity); | ||
| } | ||
| catch { } | ||
| this._nativeLoggerActive = true; | ||
| } | ||
| catch (err) { | ||
| this.logger.warn("Failed to connect native logger:", err && err.message); | ||
| } | ||
| } | ||
| this._files = files.model | ||
| this._config = config | ||
| this.logger = new QvacLogger(logger) | ||
| this.opts = opts | ||
| // The cancel hook is wired to the framework's binding.cancel(handle) | ||
| // through the public cancel() method; the createJobHandler tear-down | ||
| // flows through that path. | ||
| this._job = createJobHandler({ cancel: () => this.cancel() }) | ||
| this._run = exclusiveRunQueue() | ||
| this._handle = null | ||
| this._hparams = null | ||
| this._backendName = null | ||
| this._hasActiveResponse = false | ||
| this._nativeLoggerActive = false | ||
| this._packageName = '@qvac/vla-ggml' | ||
| this._packageVersion = require('./package.json').version | ||
| // Per-run accumulator filled by _onAddonEvent; null between runs. | ||
| this._pending = null | ||
| this.state = { configLoaded: false, weightsLoaded: false } | ||
| } | ||
| _connectNativeLogger() { | ||
| if (this._nativeLoggerActive) return | ||
| try { | ||
| binding.setLogger((priority, message) => { | ||
| const method = LOG_METHODS[priority] || 'info' | ||
| if (typeof this.logger[method] === 'function') { | ||
| this.logger[method](`[C++] ${message}`) | ||
| // Framework output callback: invoked from the JS event loop after each | ||
| // event the worker thread queues. The shape is: | ||
| // (jsHandle, eventTypeName, outputData, errorData) | ||
| // For VLA we receive at most three event types per job: | ||
| // - Output (Float32Array) — the action chunk. | ||
| // - JobEnded (RuntimeStats obj) — finishing event with timing/stats. | ||
| // - Error (string in errorData) — eventTypeName contains "Error". | ||
| // The pair is accumulated in `_pending` and surfaced through the active | ||
| // _job response (`_job.output` / `_job.end` / `_job.fail`) so the public | ||
| // `model.run(input)` Promise resolves with `{ actions, stats }` once both | ||
| // halves have arrived — preserving the previous external API even though | ||
| // the underlying dispatch is now asynchronous. | ||
| _onAddonEvent(_jsHandle, eventTypeName, outputData, errorData) { | ||
| // `_hasActiveResponse` is cleared by the response promise's .finally() in | ||
| // _runInternal, NOT here — see the rationale block there. Doing it from | ||
| // this callback would mean the flag stays set forever if the worker | ||
| // aborts before delivering JobEnded/Error. | ||
| if (typeof eventTypeName === "string" && eventTypeName.includes("Error")) { | ||
| const err = new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INFERENCE_FAILED, | ||
| adds: typeof errorData === "string" ? errorData : "native error", | ||
| }); | ||
| if (this._pending) | ||
| this._pending.actions = null; | ||
| this._pending = null; | ||
| if (this._job.active) | ||
| this._job.fail(err); | ||
| return; | ||
| } | ||
| }) | ||
| const verbosity = | ||
| this._config && Number.isInteger(this._config.verbosity) | ||
| ? this._config.verbosity | ||
| : DEFAULT_NATIVE_VERBOSITY | ||
| try { | ||
| binding.setVerbosity(verbosity) | ||
| } catch (_) {} | ||
| this._nativeLoggerActive = true | ||
| } catch (err) { | ||
| this.logger.warn('Failed to connect native logger:', err && err.message) | ||
| if (outputData instanceof Float32Array) { | ||
| if (this._pending) | ||
| this._pending.actions = outputData; | ||
| this._job.output(outputData); | ||
| return; | ||
| } | ||
| if (outputData && typeof outputData === "object") { | ||
| const stats = outputData; | ||
| const actions = this._pending ? this._pending.actions : null; | ||
| this._pending = null; | ||
| this._job.end(this.opts.stats ? stats : null, { actions, stats }); | ||
| } | ||
| } | ||
| } | ||
| // Framework output callback: invoked from the JS event loop after each | ||
| // event the worker thread queues. The shape is: | ||
| // (jsHandle, eventTypeName, outputData, errorData) | ||
| // For VLA we receive at most three event types per job: | ||
| // - Output (Float32Array) — the action chunk. | ||
| // - JobEnded (RuntimeStats obj) — finishing event with timing/stats. | ||
| // - Error (string in errorData) — eventTypeName contains "Error". | ||
| // The pair is accumulated in `_pending` and surfaced through the active | ||
| // _job response (`_job.output` / `_job.end` / `_job.fail`) so the public | ||
| // `model.run(input)` Promise resolves with `{ actions, stats }` once both | ||
| // halves have arrived — preserving the previous external API even though | ||
| // the underlying dispatch is now asynchronous. | ||
| _onAddonEvent(_jsHandle, eventTypeName, outputData, errorData) { | ||
| // `_hasActiveResponse` is cleared by the response promise's .finally() in | ||
| // _runInternal, NOT here — see the rationale block there. Doing it from | ||
| // this callback would mean the flag stays set forever if the worker | ||
| // aborts before delivering JobEnded/Error. | ||
| if (typeof eventTypeName === 'string' && eventTypeName.includes('Error')) { | ||
| const err = new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INFERENCE_FAILED, | ||
| adds: typeof errorData === 'string' ? errorData : 'native error' | ||
| }) | ||
| if (this._pending) this._pending.actions = null | ||
| this._pending = null | ||
| if (this._job.active) this._job.fail(err) | ||
| return | ||
| _releaseNativeLogger() { | ||
| if (!this._nativeLoggerActive) | ||
| return; | ||
| try { | ||
| binding.releaseLogger(); | ||
| } | ||
| catch { } | ||
| this._nativeLoggerActive = false; | ||
| } | ||
| if (outputData instanceof Float32Array) { | ||
| if (this._pending) this._pending.actions = outputData | ||
| this._job.output(outputData) | ||
| return | ||
| async load({ backend = "auto" } = {}) { | ||
| if (backend !== "auto" && backend !== "cpu") { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `backend must be 'auto' or 'cpu' (got: ${String(backend)})`, | ||
| }); | ||
| } | ||
| return this._run(async () => { | ||
| if (this.state.configLoaded) | ||
| return; | ||
| await this._load(backend); | ||
| this.state.configLoaded = true; | ||
| this.state.weightsLoaded = true; | ||
| }); | ||
| } | ||
| if (outputData && typeof outputData === 'object') { | ||
| const stats = outputData | ||
| const actions = this._pending ? this._pending.actions : null | ||
| this._pending = null | ||
| this._job.end(this.opts.stats ? stats : null, { actions, stats }) | ||
| // eslint-disable-next-line @typescript-eslint/require-await -- kept async to preserve the Promise-returning contract; the load steps are synchronous native binding calls. | ||
| async _load(backend) { | ||
| this.logger.info("Starting model load"); | ||
| this._connectNativeLogger(); | ||
| const ggufPath = pickPrimaryGgufPath(this._files); | ||
| if (!fs.existsSync(ggufPath)) { | ||
| // _connectNativeLogger has already registered a JS callback with | ||
| // the native side; without unregistering, that callback pins the | ||
| // Bare event loop and prevents the process from exiting. Release | ||
| // before throwing so a `new VlaModel(...).load()` against a | ||
| // non-existent file leaves no event-loop references behind. | ||
| this._releaseNativeLogger(); | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.MODEL_NOT_FOUND, | ||
| adds: ggufPath, | ||
| }); | ||
| } | ||
| try { | ||
| // Canonical instance lifecycle (mirrors LLM/embed/NMT): | ||
| // createInstance(jsHandle, params, outputCb) — the framework's | ||
| // JobRunner thread consumes runJob() and feeds the outputCb. | ||
| const backendsDir = this._config.backendsDir | ||
| ? this._config.backendsDir | ||
| : path.join(__dirname, "prebuilds"); | ||
| this._handle = binding.createInstance(this, { ggufPath, backend, backendsDir }, (jsHandle, eventTypeName, outputData, errorData) => { | ||
| this._onAddonEvent(jsHandle, eventTypeName, outputData, errorData); | ||
| }); | ||
| // No-op for VLA (no IModelAsyncLoad weights stream) but kept for | ||
| // symmetry with sibling addons. | ||
| binding.activate(this._handle); | ||
| this._hparams = binding.getVlaHparams(this._handle); | ||
| this._backendName = binding.getVlaBackendName(this._handle); | ||
| } | ||
| catch (loadError) { | ||
| this.logger.error("Error during model load:", loadError); | ||
| if (this._handle) { | ||
| try { | ||
| binding.destroyInstance(this._handle); | ||
| } | ||
| catch { } | ||
| this._handle = null; | ||
| } | ||
| // Same logger-leak guard as the missing-file path above. | ||
| this._releaseNativeLogger(); | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.FAILED_TO_LOAD_WEIGHTS, | ||
| adds: loadError.message, | ||
| cause: loadError, | ||
| }); | ||
| } | ||
| this.logger.info("Model load completed successfully"); | ||
| } | ||
| } | ||
| _releaseNativeLogger() { | ||
| if (!this._nativeLoggerActive) return | ||
| try { | ||
| binding.releaseLogger() | ||
| } catch (_) {} | ||
| this._nativeLoggerActive = false | ||
| } | ||
| async load({ backend = 'auto' } = {}) { | ||
| if (backend !== 'auto' && backend !== 'cpu') { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INVALID_CONFIG, | ||
| adds: `backend must be 'auto' or 'cpu' (got: ${backend})` | ||
| }) | ||
| get hparams() { | ||
| return this._hparams; | ||
| } | ||
| return this._run(async () => { | ||
| if (this.state.configLoaded) return | ||
| await this._load(backend) | ||
| this.state.configLoaded = true | ||
| this.state.weightsLoaded = true | ||
| }) | ||
| } | ||
| async _load(backend) { | ||
| this.logger.info('Starting model load') | ||
| this._connectNativeLogger() | ||
| const ggufPath = pickPrimaryGgufPath(this._files) | ||
| if (!fs.existsSync(ggufPath)) { | ||
| // _connectNativeLogger has already registered a JS callback with | ||
| // the native side; without unregistering, that callback pins the | ||
| // Bare event loop and prevents the process from exiting. Release | ||
| // before throwing so a `new VlaModel(...).load()` against a | ||
| // non-existent file leaves no event-loop references behind. | ||
| this._releaseNativeLogger() | ||
| throw new QvacErrorAddonVla({ code: ERR_CODES.MODEL_NOT_FOUND, adds: ggufPath }) | ||
| get backendName() { | ||
| return this._backendName; | ||
| } | ||
| try { | ||
| // Canonical instance lifecycle (mirrors LLM/embed/NMT): | ||
| // createInstance(jsHandle, params, outputCb) — the framework's | ||
| // JobRunner thread consumes runJob() and feeds the outputCb. | ||
| const backendsDir = | ||
| this._config && this._config.backendsDir | ||
| ? this._config.backendsDir | ||
| : path.join(__dirname, 'prebuilds') | ||
| this._handle = binding.createInstance( | ||
| this, | ||
| { ggufPath, backend, backendsDir }, | ||
| (jsHandle, eventTypeName, outputData, errorData) => { | ||
| this._onAddonEvent(jsHandle, eventTypeName, outputData, errorData) | ||
| async run(input) { | ||
| return this._run(() => this._runInternal(input)); | ||
| } | ||
| // eslint-disable-next-line @typescript-eslint/require-await -- kept async so run() can serialize it through the exclusive run queue; dispatch is fire-and-forget. | ||
| async _runInternal(input) { | ||
| if (!this._handle) { | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.INSTANCE_NOT_INITIALIZED, | ||
| }); | ||
| } | ||
| ) | ||
| // No-op for VLA (no IModelAsyncLoad weights stream) but kept for | ||
| // symmetry with sibling addons. | ||
| binding.activate(this._handle) | ||
| this._hparams = binding.getVlaHparams(this._handle) | ||
| this._backendName = binding.getVlaBackendName(this._handle) | ||
| } catch (loadError) { | ||
| this.logger.error('Error during model load:', loadError) | ||
| if (this._handle) { | ||
| if (this._hasActiveResponse) { | ||
| throw new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING }); | ||
| } | ||
| const { imgWidth, imgHeight } = validateRunInput(input, this._hparams); | ||
| this.logger.info("Starting inference"); | ||
| const response = this._job.start(); | ||
| // Per-job accumulator. Two events flow through _onAddonEvent: the | ||
| // Float32Array action chunk lands first, then the RuntimeStats object — | ||
| // we resolve the response only when both have arrived. | ||
| this._pending = { actions: null }; | ||
| let accepted = false; | ||
| try { | ||
| binding.destroyInstance(this._handle) | ||
| } catch (_) {} | ||
| this._handle = null | ||
| } | ||
| // Same logger-leak guard as the missing-file path above. | ||
| this._releaseNativeLogger() | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.FAILED_TO_LOAD_WEIGHTS, | ||
| adds: loadError.message, | ||
| cause: loadError | ||
| }) | ||
| accepted = binding.runJob(this._handle, { | ||
| type: "vla", | ||
| input: { | ||
| images: input.images, | ||
| imgWidth, | ||
| imgHeight, | ||
| state: input.state, | ||
| tokens: input.tokens, | ||
| mask: input.mask, | ||
| noise: input.noise ?? undefined, | ||
| }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| this._pending = null; | ||
| this._job.fail(err); | ||
| throw err; | ||
| } | ||
| if (!accepted) { | ||
| this._pending = null; | ||
| const err = new QvacErrorAddonVla({ | ||
| code: ERR_CODES.JOB_ALREADY_RUNNING, | ||
| }); | ||
| this._job.fail(err); | ||
| throw err; | ||
| } | ||
| // Only mark the model busy once the worker has actually accepted the job. | ||
| // Clear via `.finally()` on the response promise, not from inside the | ||
| // native event callback — if the worker thread aborts mid-inference | ||
| // (e.g. an unrecoverable GGML_ASSERT in smolvla.cpp) no JobEnded/Error | ||
| // event is delivered and the previous "clear from _onAddonEvent" pattern | ||
| // would leave the flag set forever, wedging every subsequent run() with | ||
| // JOB_ALREADY_RUNNING. Mirrors qvac-lib-infer-llamacpp-llm/index.js. | ||
| this._hasActiveResponse = true; | ||
| const finalized = response.await().finally(() => { | ||
| this._hasActiveResponse = false; | ||
| }); | ||
| // Swallow rejections at the unobserved-promise level so an awaiter who | ||
| // catches still sees the rejection through their own await; without | ||
| // this the runtime logs an "unhandled promise rejection" warning. | ||
| finalized.catch((err) => { | ||
| this.logger?.warn?.("Inference response rejected:", err?.message || err); | ||
| }); | ||
| // Make response.await() idempotent: subsequent calls return the same | ||
| // chained promise so .finally() fires exactly once. | ||
| response.await = () => finalized; | ||
| this.logger.info("Inference job dispatched"); | ||
| return response; | ||
| } | ||
| this.logger.info('Model load completed successfully') | ||
| } | ||
| get hparams() { | ||
| return this._hparams | ||
| } | ||
| get backendName() { | ||
| return this._backendName | ||
| } | ||
| async run(input) { | ||
| return this._run(() => this._runInternal(input)) | ||
| } | ||
| async _runInternal(input) { | ||
| if (!this._handle) { | ||
| throw new QvacErrorAddonVla({ code: ERR_CODES.INSTANCE_NOT_INITIALIZED }) | ||
| async pause() { | ||
| /* no-op: SmolVLA inference has no per-step cancel point */ | ||
| } | ||
| if (this._hasActiveResponse) { | ||
| throw new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING }) | ||
| } | ||
| const { imgWidth, imgHeight } = validateRunInput(input, this._hparams) | ||
| this.logger.info('Starting inference') | ||
| const response = this._job.start() | ||
| // Per-job accumulator. Two events flow through _onAddonEvent: the | ||
| // Float32Array action chunk lands first, then the RuntimeStats object — | ||
| // we resolve the response only when both have arrived. | ||
| this._pending = { actions: null } | ||
| let accepted = false | ||
| try { | ||
| accepted = binding.runJob(this._handle, { | ||
| type: 'vla', | ||
| input: { | ||
| images: input.images, | ||
| imgWidth, | ||
| imgHeight, | ||
| state: input.state, | ||
| tokens: input.tokens, | ||
| mask: input.mask, | ||
| noise: input.noise ?? undefined | ||
| async cancel() { | ||
| if (this._handle) { | ||
| try { | ||
| await binding.cancel(this._handle); | ||
| } | ||
| catch { } | ||
| } | ||
| }) | ||
| } catch (err) { | ||
| this._pending = null | ||
| this._job.fail(err) | ||
| throw err | ||
| } | ||
| if (!accepted) { | ||
| this._pending = null | ||
| const err = new QvacErrorAddonVla({ code: ERR_CODES.JOB_ALREADY_RUNNING }) | ||
| this._job.fail(err) | ||
| throw err | ||
| async unload() { | ||
| return this._run(async () => { | ||
| await this.cancel(); | ||
| if (this._job.active) { | ||
| this._job.fail(new QvacErrorAddonVla({ code: ERR_CODES.MODEL_UNLOADED })); | ||
| } | ||
| this._pending = null; | ||
| this._hasActiveResponse = false; | ||
| if (this._handle) { | ||
| try { | ||
| binding.destroyInstance(this._handle); | ||
| } | ||
| catch (destroyError) { | ||
| this._handle = null; | ||
| this._releaseNativeLogger(); | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.FAILED_TO_DESTROY, | ||
| adds: destroyError.message, | ||
| cause: destroyError, | ||
| }); | ||
| } | ||
| this._handle = null; | ||
| } | ||
| this._releaseNativeLogger(); | ||
| this._hparams = null; | ||
| this._backendName = null; | ||
| this.state.configLoaded = false; | ||
| this.state.weightsLoaded = false; | ||
| }); | ||
| } | ||
| // Only mark the model busy once the worker has actually accepted the job. | ||
| // Clear via `.finally()` on the response promise, not from inside the | ||
| // native event callback — if the worker thread aborts mid-inference | ||
| // (e.g. an unrecoverable GGML_ASSERT in smolvla.cpp) no JobEnded/Error | ||
| // event is delivered and the previous "clear from _onAddonEvent" pattern | ||
| // would leave the flag set forever, wedging every subsequent run() with | ||
| // JOB_ALREADY_RUNNING. Mirrors qvac-lib-infer-llamacpp-llm/index.js. | ||
| this._hasActiveResponse = true | ||
| const finalized = response.await().finally(() => { | ||
| this._hasActiveResponse = false | ||
| }) | ||
| // Swallow rejections at the unobserved-promise level so an awaiter who | ||
| // catches still sees the rejection through their own await; without | ||
| // this the runtime logs an "unhandled promise rejection" warning. | ||
| finalized.catch((err) => { | ||
| this.logger?.warn?.('Inference response rejected:', err?.message || err) | ||
| }) | ||
| // Make response.await() idempotent: subsequent calls return the same | ||
| // chained promise so .finally() fires exactly once. | ||
| response.await = () => finalized | ||
| this.logger.info('Inference job dispatched') | ||
| return response | ||
| } | ||
| async pause() { | ||
| /* no-op: SmolVLA inference has no per-step cancel point */ | ||
| } | ||
| async cancel() { | ||
| if (this._handle) { | ||
| try { | ||
| await binding.cancel(this._handle) | ||
| } catch (_) {} | ||
| getState() { | ||
| return this.state; | ||
| } | ||
| } | ||
| async unload() { | ||
| return this._run(async () => { | ||
| await this.cancel() | ||
| if (this._job.active) { | ||
| this._job.fail(new QvacErrorAddonVla({ code: ERR_CODES.MODEL_UNLOADED })) | ||
| } | ||
| this._pending = null | ||
| this._hasActiveResponse = false | ||
| if (this._handle) { | ||
| try { | ||
| binding.destroyInstance(this._handle) | ||
| } catch (destroyError) { | ||
| this._handle = null | ||
| this._releaseNativeLogger() | ||
| throw new QvacErrorAddonVla({ | ||
| code: ERR_CODES.FAILED_TO_DESTROY, | ||
| adds: destroyError.message, | ||
| cause: destroyError | ||
| }) | ||
| } | ||
| this._handle = null | ||
| } | ||
| this._releaseNativeLogger() | ||
| this._hparams = null | ||
| this._backendName = null | ||
| this.state.configLoaded = false | ||
| this.state.weightsLoaded = false | ||
| }) | ||
| } | ||
| getState() { | ||
| return this.state | ||
| } | ||
| } | ||
| module.exports = VlaModel | ||
| module.exports.VlaModel = VlaModel | ||
| module.exports.preprocessImage = preprocessImage | ||
| module.exports.padState = padState | ||
| module.exports.DEFAULT_IMAGE_SIZE = DEFAULT_IMAGE_SIZE | ||
| module.exports.QvacErrorAddonVla = QvacErrorAddonVla | ||
| module.exports.ERR_CODES = ERR_CODES | ||
| // Alias used by the namespace below to re-export the class as a property of | ||
| // itself (`VlaModel.VlaModel`). `export import VlaModel = VlaModel` inside the | ||
| // namespace would resolve to the alias being declared, so the indirection is | ||
| // required. | ||
| var VlaModelClass = VlaModel; | ||
| /** | ||
| * Declaration merging with the class above models this package's CommonJS | ||
| * export shape — `module.exports` IS the `VlaModel` constructor, carrying the | ||
| * named exports as own properties — directly in the type system. TypeScript | ||
| * emits the property attachments natively, so the generated `index.js` and | ||
| * `index.d.ts` can no longer drift from each other, and a CommonJS consumer | ||
| * (`import VlaModel = require('@qvac/vla-ggml')`) gets a real construct | ||
| * signature instead of TS2351. | ||
| */ | ||
| // eslint-disable-next-line @typescript-eslint/no-namespace -- class/namespace merging is the only way to type a constructor-first CommonJS export (`module.exports = VlaModel` plus attached members). | ||
| (function (VlaModel) { | ||
| VlaModel.VlaModel = VlaModelClass; | ||
| VlaModel.preprocessImage = addonModule.preprocessImage; | ||
| VlaModel.padState = addonModule.padState; | ||
| VlaModel.DEFAULT_IMAGE_SIZE = addonModule.DEFAULT_IMAGE_SIZE; | ||
| VlaModel.QvacErrorAddonVla = errorModule.QvacErrorAddonVla; | ||
| VlaModel.ERR_CODES = errorModule.ERR_CODES; | ||
| })(VlaModel || (VlaModel = {})); | ||
| module.exports = VlaModel; |
+59
-63
@@ -1,74 +0,70 @@ | ||
| 'use strict' | ||
| const { QvacErrorBase, addCodes } = require('@qvac/error') | ||
| const { name, version } = require('../package.json') | ||
| class QvacErrorAddonVla extends QvacErrorBase {} | ||
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.ERR_CODES = exports.QvacErrorAddonVla = void 0; | ||
| /* eslint-disable @typescript-eslint/no-require-imports -- @qvac/error exposes a CommonJS export shape. */ | ||
| const QvacError = require("@qvac/error"); | ||
| /* eslint-enable @typescript-eslint/no-require-imports */ | ||
| const { QvacErrorBase, addCodes } = QvacError; | ||
| class QvacErrorAddonVla extends QvacErrorBase { | ||
| } | ||
| exports.QvacErrorAddonVla = QvacErrorAddonVla; | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports -- package metadata is read from the package root at runtime. | ||
| const { name, version } = require("../package.json"); | ||
| // This library has error code range from 30001 to 31000 | ||
| const ERR_CODES = Object.freeze({ | ||
| FAILED_TO_LOAD_WEIGHTS: 30001, | ||
| FAILED_TO_DESTROY: 30002, | ||
| MODEL_NOT_FOUND: 30003, | ||
| INVALID_CONFIG: 30004, | ||
| MISSING_REQUIRED_PARAMETER: 30005, | ||
| INVALID_INPUT: 30006, | ||
| JOB_ALREADY_RUNNING: 30007, | ||
| INSTANCE_NOT_INITIALIZED: 30008, | ||
| MODEL_UNLOADED: 30009, | ||
| INFERENCE_FAILED: 30010 | ||
| }) | ||
| addCodes( | ||
| { | ||
| [ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: { | ||
| name: 'FAILED_TO_LOAD_WEIGHTS', | ||
| message: (message) => `Failed to load weights, error: ${message}` | ||
| exports.ERR_CODES = Object.freeze({ | ||
| FAILED_TO_LOAD_WEIGHTS: 30001, | ||
| FAILED_TO_DESTROY: 30002, | ||
| MODEL_NOT_FOUND: 30003, | ||
| INVALID_CONFIG: 30004, | ||
| MISSING_REQUIRED_PARAMETER: 30005, | ||
| INVALID_INPUT: 30006, | ||
| JOB_ALREADY_RUNNING: 30007, | ||
| INSTANCE_NOT_INITIALIZED: 30008, | ||
| MODEL_UNLOADED: 30009, | ||
| INFERENCE_FAILED: 30010, | ||
| }); | ||
| addCodes({ | ||
| [exports.ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: { | ||
| name: "FAILED_TO_LOAD_WEIGHTS", | ||
| message: (message) => `Failed to load weights, error: ${message}`, | ||
| }, | ||
| [ERR_CODES.FAILED_TO_DESTROY]: { | ||
| name: 'FAILED_TO_DESTROY', | ||
| message: (message) => `Failed to destroy instance, error: ${message}` | ||
| [exports.ERR_CODES.FAILED_TO_DESTROY]: { | ||
| name: "FAILED_TO_DESTROY", | ||
| message: (message) => `Failed to destroy instance, error: ${message}`, | ||
| }, | ||
| [ERR_CODES.MODEL_NOT_FOUND]: { | ||
| name: 'MODEL_NOT_FOUND', | ||
| message: (path) => `SmolVLA GGUF not found: ${path}` | ||
| [exports.ERR_CODES.MODEL_NOT_FOUND]: { | ||
| name: "MODEL_NOT_FOUND", | ||
| message: (path) => `SmolVLA GGUF not found: ${path}`, | ||
| }, | ||
| [ERR_CODES.INVALID_CONFIG]: { | ||
| name: 'INVALID_CONFIG', | ||
| message: (message) => `Invalid configuration: ${message}` | ||
| [exports.ERR_CODES.INVALID_CONFIG]: { | ||
| name: "INVALID_CONFIG", | ||
| message: (message) => `Invalid configuration: ${message}`, | ||
| }, | ||
| [ERR_CODES.MISSING_REQUIRED_PARAMETER]: { | ||
| name: 'MISSING_REQUIRED_PARAMETER', | ||
| message: (paramName) => `Missing required parameter: ${paramName}` | ||
| [exports.ERR_CODES.MISSING_REQUIRED_PARAMETER]: { | ||
| name: "MISSING_REQUIRED_PARAMETER", | ||
| message: (paramName) => `Missing required parameter: ${paramName}`, | ||
| }, | ||
| [ERR_CODES.INVALID_INPUT]: { | ||
| name: 'INVALID_INPUT', | ||
| message: (message) => `Invalid input: ${message}` | ||
| [exports.ERR_CODES.INVALID_INPUT]: { | ||
| name: "INVALID_INPUT", | ||
| message: (message) => `Invalid input: ${message}`, | ||
| }, | ||
| [ERR_CODES.JOB_ALREADY_RUNNING]: { | ||
| name: 'JOB_ALREADY_RUNNING', | ||
| message: () => 'Cannot set new job: a job is already set or being processed' | ||
| [exports.ERR_CODES.JOB_ALREADY_RUNNING]: { | ||
| name: "JOB_ALREADY_RUNNING", | ||
| message: () => "Cannot set new job: a job is already set or being processed", | ||
| }, | ||
| [ERR_CODES.INSTANCE_NOT_INITIALIZED]: { | ||
| name: 'INSTANCE_NOT_INITIALIZED', | ||
| message: () => 'Addon not initialized. Call load() first.' | ||
| [exports.ERR_CODES.INSTANCE_NOT_INITIALIZED]: { | ||
| name: "INSTANCE_NOT_INITIALIZED", | ||
| message: () => "Addon not initialized. Call load() first.", | ||
| }, | ||
| [ERR_CODES.MODEL_UNLOADED]: { | ||
| name: 'MODEL_UNLOADED', | ||
| message: () => 'Model was unloaded' | ||
| [exports.ERR_CODES.MODEL_UNLOADED]: { | ||
| name: "MODEL_UNLOADED", | ||
| message: () => "Model was unloaded", | ||
| }, | ||
| [ERR_CODES.INFERENCE_FAILED]: { | ||
| name: 'INFERENCE_FAILED', | ||
| message: (message) => `Inference failed: ${message}` | ||
| } | ||
| }, | ||
| { | ||
| [exports.ERR_CODES.INFERENCE_FAILED]: { | ||
| name: "INFERENCE_FAILED", | ||
| message: (message) => `Inference failed: ${message}`, | ||
| }, | ||
| }, { | ||
| name, | ||
| version | ||
| } | ||
| ) | ||
| module.exports = { | ||
| ERR_CODES, | ||
| QvacErrorAddonVla | ||
| } | ||
| version, | ||
| }); |
+23
-9
| { | ||
| "name": "@qvac/vla-ggml", | ||
| "version": "0.15.0", | ||
| "version": "0.16.1", | ||
| "description": "VLA vision-language-action inference addon for QVAC (ggml backend)", | ||
@@ -10,13 +10,20 @@ "addon": true, | ||
| "scripts": { | ||
| "build": "bare-make generate && bare-make build && bare-make install", | ||
| "build:pack": "mkdir -p dist && npm pack --pack-destination dist", | ||
| "build": "npm run build:ts && npm run build:native", | ||
| "build:native": "bare-make generate && bare-make build && bare-make install", | ||
| "build:ts": "tsc -p tsconfig.build.json", | ||
| "build:pack": "npm run build:ts && mkdir -p dist && npm pack --pack-destination dist", | ||
| "check:generated": "npm run build:ts && git diff --exit-code -- index.js addon.js index.d.ts addon.d.ts lib/error.js lib/error.d.ts", | ||
| "typecheck": "tsc --noEmit -p tsconfig.json", | ||
| "mobile:copy-prebuilds": "cp -r prebuilds/android-arm64 prebuilds/android-ia32 || echo 'Warning: Failed to copy vla prebuilds to android-ia32'; cp -r prebuilds/android-arm64 prebuilds/android-arm || echo 'Warning: Failed to copy vla prebuilds to android-arm'; cp -r prebuilds/android-arm64 prebuilds/android-x64 || echo 'Warning: Failed to copy vla prebuilds to android-x64'; cp -r prebuilds/ios-arm64 prebuilds/ios-arm64-simulator 2>/dev/null || echo 'iOS prebuilds already present'; cp -r prebuilds/ios-arm64 prebuilds/ios-x64-simulator 2>/dev/null || echo 'iOS prebuilds already present'", | ||
| "lint": "prettier --check \"**/*.{js,cjs,mjs}\" && lunte", | ||
| "lint": "npm run lint:js && npm run lint:ts", | ||
| "lint:js": "prettier --check \"**/*.{js,cjs,mjs}\" && lunte", | ||
| "lint:ts": "eslint \"src/**/*.ts\" --max-warnings=0", | ||
| "lint:ts:fix": "eslint \"src/**/*.ts\" --fix", | ||
| "format": "prettier --write \"**/*.{js,cjs,mjs}\"", | ||
| "lint:cpp": "clang-tidy -p build $(find addon -name '*.cpp')", | ||
| "test": "npm run test:unit && npm run test:integration", | ||
| "test:integration": "npm run test:integration:generate && bare test/integration/all.js --exit", | ||
| "test": "npm run lint && npm run test:types && npm run test:unit && npm run test:integration", | ||
| "test:integration": "npm run build:ts && npm run test:integration:generate && bare test/integration/all.js --exit", | ||
| "test:integration:generate": "brittle -r test/integration/all.js test/integration/*.test.js && npm run test:mobile:generate", | ||
| "test:unit:generate": "brittle -r test/unit/all.js test/unit/*.test.js", | ||
| "test:unit": "npm run test:unit:generate && bare test/unit/all.js --exit", | ||
| "test:unit": "npm run build:ts && npm run test:unit:generate && bare test/unit/all.js --exit", | ||
| "test:cpp:build": "bare-make generate -D BUILD_TESTING=ON && bare-make build --target addon-test", | ||
@@ -33,6 +40,9 @@ "test:cpp:run": "cd build/test/unit/ && ./addon-test --gtest_output=xml:cpp-test-results.xml", | ||
| "test:mobile:validate": "node scripts/validate-mobile-tests.js", | ||
| "test:dts": "tsc -p tsconfig.dts.json" | ||
| "test:types": "npm run typecheck && npm run check:generated && npm run test:types:consumer", | ||
| "test:types:consumer": "tsc --noEmit -p test/types/tsconfig.cjs.json && tsc --noEmit -p test/types/tsconfig.esm.json", | ||
| "test:dts": "npm run test:types" | ||
| }, | ||
| "files": [ | ||
| "addon.js", | ||
| "addon.d.ts", | ||
| "binding.js", | ||
@@ -67,6 +77,10 @@ "index.js", | ||
| "cmake-vcpkg": "^1.1.0", | ||
| "eslint": "^9.39.4", | ||
| "eslint-import-resolver-typescript": "^4.4.5", | ||
| "eslint-plugin-import": "^2.32.0", | ||
| "lunte": "^1.8.0", | ||
| "prettier": "^3.6.2", | ||
| "prettier-config-holepunch": "^2.0.0", | ||
| "typescript": "^5.9.2" | ||
| "typescript": "^5.9.2", | ||
| "typescript-eslint": "^8.63.0" | ||
| }, | ||
@@ -73,0 +87,0 @@ "exports": { |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
516739151
0.17%57
3.64%994
21.07%12
50%41
2.5%