omni-viewer-core 0.6.0 → 0.7.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 +20 -0
- package/dist/parsers/safetensors/index.d.ts +22 -0
- package/dist/parsers/safetensors/index.js +70 -9
- package/dist/parsers/safetensors/node.d.ts +13 -0
- package/dist/parsers/safetensors/node.js +52 -0
- package/dist/viewers/safetensors/index.d.ts +9 -1
- package/dist/viewers/safetensors/index.js +30 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -63,6 +63,26 @@ Viewers follow the same pattern — a `mount*Viewer(input, container, ctx,
|
|
|
63
63
|
deps)` entry per format, plus `self-loading` variants that dynamically import
|
|
64
64
|
their own dependencies for hosts that don't want to wire them manually.
|
|
65
65
|
|
|
66
|
+
### Safetensors large-file input
|
|
67
|
+
|
|
68
|
+
Pass browser `File`/`Blob` objects through the lazy source helper so only the
|
|
69
|
+
8-byte length prefix and JSON header are read:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
createSafetensorsBlobSource,
|
|
74
|
+
mountSafetensorsViewer
|
|
75
|
+
} from 'omni-viewer-core/viewers/safetensors';
|
|
76
|
+
|
|
77
|
+
const source = createSafetensorsBlobSource(file, file.name);
|
|
78
|
+
await mountSafetensorsViewer(source, container, ctx);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Node hosts can use `parseSafetensorsFile` from
|
|
82
|
+
`omni-viewer-core/parsers/safetensors/node` and mount the returned document
|
|
83
|
+
with `mountSafetensorsDocument`. The legacy `Uint8Array` input remains
|
|
84
|
+
available, but it requires the host to load the complete file first.
|
|
85
|
+
|
|
66
86
|
### PDF host integration
|
|
67
87
|
|
|
68
88
|
The PDF viewer asks `ctx.assets.resolveAssetUrl` for the exact key
|
|
@@ -16,6 +16,17 @@ export interface SafetensorsDocument {
|
|
|
16
16
|
rawPreview?: string | undefined;
|
|
17
17
|
warnings: string[];
|
|
18
18
|
}
|
|
19
|
+
/** Random-access source used to inspect a safetensors file without loading its payload. */
|
|
20
|
+
export interface SafetensorsSource {
|
|
21
|
+
/** Complete file size in bytes. */
|
|
22
|
+
readonly size: number;
|
|
23
|
+
/** Reads up to `length` bytes at the absolute `offset`. */
|
|
24
|
+
read(offset: number, length: number, signal?: AbortSignal): Uint8Array | Promise<Uint8Array>;
|
|
25
|
+
}
|
|
26
|
+
export interface SafetensorsSourceParseOptions {
|
|
27
|
+
fileSize?: string;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}
|
|
19
30
|
/** One tensor as declared in the safetensors header. */
|
|
20
31
|
export interface SafetensorsTensor {
|
|
21
32
|
name: string;
|
|
@@ -29,6 +40,17 @@ export interface SafetensorsTensor {
|
|
|
29
40
|
byteLength: number;
|
|
30
41
|
}
|
|
31
42
|
export declare function parseSafetensors(input: Uint8Array, fileSize?: string): SafetensorsDocument;
|
|
43
|
+
/**
|
|
44
|
+
* Reads and parses only the safetensors header from a random-access source.
|
|
45
|
+
* The tensor payload range is validated against `source.size` but never read.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parseSafetensorsSource(source: SafetensorsSource, options?: SafetensorsSourceParseOptions): Promise<SafetensorsDocument>;
|
|
48
|
+
/**
|
|
49
|
+
* Parses bytes containing the 8-byte length prefix and complete JSON header.
|
|
50
|
+
* `totalFileBytes` is the size of the original file and is used to validate
|
|
51
|
+
* tensor offsets without requiring tensor payload bytes in `input`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseSafetensorsHeader(input: Uint8Array, totalFileBytes: number, fileSize?: string): SafetensorsDocument;
|
|
32
54
|
/** Compact parameter counts, e.g. 1.20B, 350.0M, 12.3K. */
|
|
33
55
|
export declare function formatCount(count: number): string;
|
|
34
56
|
export declare function formatFileSize(bytes: number): string;
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The format is a fixed 8-byte little-endian header length, followed by a UTF-8
|
|
5
5
|
* JSON header (tensor name → {dtype, shape, data_offsets}, plus an optional
|
|
6
|
-
* "__metadata__" string map), followed by the raw tensor buffer.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* "__metadata__" string map), followed by the raw tensor buffer. Use
|
|
7
|
+
* {@link parseSafetensorsSource} for large files: it reads only the 8-byte length
|
|
8
|
+
* prefix and JSON header. {@link parseSafetensors} remains the compatibility API
|
|
9
|
+
* for callers that already have the complete file in memory.
|
|
9
10
|
*
|
|
10
11
|
* Reference: https://github.com/huggingface/safetensors
|
|
11
12
|
*/
|
|
@@ -25,19 +26,65 @@ const DTYPE_BITS = {
|
|
|
25
26
|
F6_E2M3: 6, F6_E3M2: 6, F4: 4
|
|
26
27
|
};
|
|
27
28
|
export function parseSafetensors(input, fileSize = formatFileSize(input.byteLength)) {
|
|
29
|
+
return parseSafetensorsHeader(input, input.byteLength, fileSize);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads and parses only the safetensors header from a random-access source.
|
|
33
|
+
* The tensor payload range is validated against `source.size` but never read.
|
|
34
|
+
*/
|
|
35
|
+
export async function parseSafetensorsSource(source, options = {}) {
|
|
36
|
+
const fileSize = options.fileSize ?? formatFileSize(source.size);
|
|
37
|
+
if (!Number.isSafeInteger(source.size) || source.size < 0) {
|
|
38
|
+
return invalid(fileSize, 'File size is out of range.');
|
|
39
|
+
}
|
|
40
|
+
throwIfAborted(options.signal);
|
|
41
|
+
const prefix = await source.read(0, HEADER_LENGTH_BYTES, options.signal);
|
|
42
|
+
throwIfAborted(options.signal);
|
|
43
|
+
if (prefix.byteLength < HEADER_LENGTH_BYTES) {
|
|
44
|
+
return invalid(fileSize, 'File is too small to contain a safetensors header.');
|
|
45
|
+
}
|
|
46
|
+
const headerLength = readHeaderLength(prefix);
|
|
47
|
+
const lengthIssue = validateHeaderLength(headerLength);
|
|
48
|
+
if (lengthIssue)
|
|
49
|
+
return invalid(fileSize, lengthIssue);
|
|
50
|
+
const headerEnd = HEADER_LENGTH_BYTES + headerLength;
|
|
51
|
+
if (headerEnd > source.size) {
|
|
52
|
+
return invalid(fileSize, 'Declared header length extends past the end of the file.');
|
|
53
|
+
}
|
|
54
|
+
const jsonHeader = await source.read(HEADER_LENGTH_BYTES, headerLength, options.signal);
|
|
55
|
+
throwIfAborted(options.signal);
|
|
56
|
+
if (jsonHeader.byteLength < headerLength) {
|
|
57
|
+
return invalid(fileSize, 'The complete safetensors header could not be read.');
|
|
58
|
+
}
|
|
59
|
+
const headerBytes = new Uint8Array(headerEnd);
|
|
60
|
+
headerBytes.set(prefix.subarray(0, HEADER_LENGTH_BYTES));
|
|
61
|
+
headerBytes.set(jsonHeader.subarray(0, headerLength), HEADER_LENGTH_BYTES);
|
|
62
|
+
return parseSafetensorsHeader(headerBytes, source.size, fileSize);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parses bytes containing the 8-byte length prefix and complete JSON header.
|
|
66
|
+
* `totalFileBytes` is the size of the original file and is used to validate
|
|
67
|
+
* tensor offsets without requiring tensor payload bytes in `input`.
|
|
68
|
+
*/
|
|
69
|
+
export function parseSafetensorsHeader(input, totalFileBytes, fileSize = formatFileSize(totalFileBytes)) {
|
|
28
70
|
const warnings = [];
|
|
29
71
|
if (input.byteLength < HEADER_LENGTH_BYTES) {
|
|
30
72
|
return invalid(fileSize, 'File is too small to contain a safetensors header.');
|
|
31
73
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!Number.isSafeInteger(headerLength) || headerLength <= 0 || headerLength > MAX_HEADER_BYTES) {
|
|
35
|
-
return invalid(fileSize, 'Header length is out of range; the file is not a valid safetensors file.');
|
|
74
|
+
if (!Number.isSafeInteger(totalFileBytes) || totalFileBytes < 0) {
|
|
75
|
+
return invalid(fileSize, 'File size is out of range.');
|
|
36
76
|
}
|
|
77
|
+
const headerLength = readHeaderLength(input);
|
|
78
|
+
const lengthIssue = validateHeaderLength(headerLength);
|
|
79
|
+
if (lengthIssue)
|
|
80
|
+
return invalid(fileSize, lengthIssue);
|
|
37
81
|
const headerEnd = HEADER_LENGTH_BYTES + headerLength;
|
|
38
|
-
if (headerEnd >
|
|
82
|
+
if (headerEnd > totalFileBytes) {
|
|
39
83
|
return invalid(fileSize, 'Declared header length extends past the end of the file.');
|
|
40
84
|
}
|
|
85
|
+
if (headerEnd > input.byteLength) {
|
|
86
|
+
return invalid(fileSize, 'The complete safetensors header could not be read.');
|
|
87
|
+
}
|
|
41
88
|
const headerBytes = input.subarray(HEADER_LENGTH_BYTES, headerEnd);
|
|
42
89
|
if (headerBytes[0] !== 0x7b) {
|
|
43
90
|
return invalid(fileSize, 'The safetensors header must begin with a JSON object.');
|
|
@@ -72,7 +119,7 @@ export function parseSafetensors(input, fileSize = formatFileSize(input.byteLeng
|
|
|
72
119
|
}
|
|
73
120
|
if (metadataIssue)
|
|
74
121
|
warnings.push('Some "__metadata__" values are not strings and were ignored.');
|
|
75
|
-
const dataBufferSize =
|
|
122
|
+
const dataBufferSize = totalFileBytes - headerEnd;
|
|
76
123
|
const tensors = [];
|
|
77
124
|
const dtypeCounts = new Map();
|
|
78
125
|
let totalElements = 0;
|
|
@@ -184,6 +231,20 @@ export function parseSafetensors(input, fileSize = formatFileSize(input.byteLeng
|
|
|
184
231
|
warnings
|
|
185
232
|
};
|
|
186
233
|
}
|
|
234
|
+
function readHeaderLength(input) {
|
|
235
|
+
const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
|
|
236
|
+
return Number(view.getBigUint64(0, true));
|
|
237
|
+
}
|
|
238
|
+
function validateHeaderLength(headerLength) {
|
|
239
|
+
return !Number.isSafeInteger(headerLength) || headerLength <= 0 || headerLength > MAX_HEADER_BYTES
|
|
240
|
+
? 'Header length is out of range; the file is not a valid safetensors file.'
|
|
241
|
+
: undefined;
|
|
242
|
+
}
|
|
243
|
+
function throwIfAborted(signal) {
|
|
244
|
+
if (!signal?.aborted)
|
|
245
|
+
return;
|
|
246
|
+
throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
|
|
247
|
+
}
|
|
187
248
|
function safeElementCount(shape) {
|
|
188
249
|
let count = 1;
|
|
189
250
|
for (const dimension of shape) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type SafetensorsDocument, type SafetensorsSource } from './index.js';
|
|
2
|
+
/** File-descriptor-backed source that reads only requested safetensors ranges. */
|
|
3
|
+
export declare class SafetensorsFileSource implements SafetensorsSource {
|
|
4
|
+
private readonly fd;
|
|
5
|
+
readonly size: number;
|
|
6
|
+
private closed;
|
|
7
|
+
private constructor();
|
|
8
|
+
static open(filePath: string): SafetensorsFileSource;
|
|
9
|
+
read(offset: number, length: number, signal?: AbortSignal): Uint8Array;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Parses a safetensors file after reading only its length prefix and JSON header. */
|
|
13
|
+
export declare function parseSafetensorsFile(filePath: string, fileSize?: string): Promise<SafetensorsDocument>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { formatFileSize, parseSafetensorsSource } from './index.js';
|
|
3
|
+
const EMPTY = new Uint8Array(0);
|
|
4
|
+
/** File-descriptor-backed source that reads only requested safetensors ranges. */
|
|
5
|
+
export class SafetensorsFileSource {
|
|
6
|
+
fd;
|
|
7
|
+
size;
|
|
8
|
+
closed = false;
|
|
9
|
+
constructor(fd, size) {
|
|
10
|
+
this.fd = fd;
|
|
11
|
+
this.size = size;
|
|
12
|
+
}
|
|
13
|
+
static open(filePath) {
|
|
14
|
+
const fd = fs.openSync(filePath, 'r');
|
|
15
|
+
try {
|
|
16
|
+
return new SafetensorsFileSource(fd, fs.fstatSync(fd).size);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
fs.closeSync(fd);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
read(offset, length, signal) {
|
|
24
|
+
if (this.closed)
|
|
25
|
+
throw new Error('Safetensors source is closed');
|
|
26
|
+
if (signal?.aborted)
|
|
27
|
+
throw signal.reason ?? new Error('The operation was aborted.');
|
|
28
|
+
const start = Math.max(0, offset);
|
|
29
|
+
const end = Math.min(start + Math.max(0, length), this.size);
|
|
30
|
+
if (end <= start)
|
|
31
|
+
return EMPTY;
|
|
32
|
+
const bytes = new Uint8Array(end - start);
|
|
33
|
+
const bytesRead = fs.readSync(this.fd, bytes, 0, bytes.byteLength, start);
|
|
34
|
+
return bytesRead === bytes.byteLength ? bytes : bytes.subarray(0, bytesRead);
|
|
35
|
+
}
|
|
36
|
+
close() {
|
|
37
|
+
if (this.closed)
|
|
38
|
+
return;
|
|
39
|
+
this.closed = true;
|
|
40
|
+
fs.closeSync(this.fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Parses a safetensors file after reading only its length prefix and JSON header. */
|
|
44
|
+
export async function parseSafetensorsFile(filePath, fileSize) {
|
|
45
|
+
const source = SafetensorsFileSource.open(filePath);
|
|
46
|
+
try {
|
|
47
|
+
return await parseSafetensorsSource(source, { fileSize: fileSize ?? formatFileSize(source.size) });
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
source.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClipboardService, HostContext } from '../../host/index.js';
|
|
2
|
-
import { type SafetensorsDocument } from '../../parsers/safetensors/index.js';
|
|
2
|
+
import { type SafetensorsDocument, type SafetensorsSource } from '../../parsers/safetensors/index.js';
|
|
3
3
|
import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
|
|
4
4
|
export { safetensorsViewerCss } from './styles.js';
|
|
5
5
|
export declare const SAFETENSORS_VIEWER_META: {
|
|
@@ -14,6 +14,14 @@ export declare const SAFETENSORS_VIEWER_META: {
|
|
|
14
14
|
export type SafetensorsViewerContext = HostContext & {
|
|
15
15
|
clipboard?: ClipboardService;
|
|
16
16
|
};
|
|
17
|
+
/** Lazy input used by large-file hosts to avoid materializing tensor payloads. */
|
|
18
|
+
export interface SafetensorsViewerSource extends SafetensorsSource {
|
|
19
|
+
fileName: string;
|
|
20
|
+
lastModified?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Creates a lazy safetensors input backed by browser `Blob.slice()` range reads. */
|
|
23
|
+
export declare function createSafetensorsBlobSource(blob: Blob, fileName?: string): SafetensorsViewerSource;
|
|
17
24
|
export declare function mountSafetensorsViewer(input: ViewerInput, container: HTMLElement, ctx: SafetensorsViewerContext, options?: MountOptions): Promise<ViewerHandle>;
|
|
25
|
+
export declare function mountSafetensorsViewer(input: SafetensorsViewerSource, container: HTMLElement, ctx: SafetensorsViewerContext, options?: MountOptions): Promise<ViewerHandle>;
|
|
18
26
|
/** Mount a pre-parsed model, allowing Node adapters to retain random-access parsing. */
|
|
19
27
|
export declare function mountSafetensorsDocument(document: SafetensorsDocument, fileName: string, container: HTMLElement, ctx: SafetensorsViewerContext, options?: MountOptions): ViewerHandle;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseSafetensors } from '../../parsers/safetensors/index.js';
|
|
1
|
+
import { parseSafetensors, parseSafetensorsSource } from '../../parsers/safetensors/index.js';
|
|
2
2
|
import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
|
|
3
3
|
import { safetensorsViewerCss } from './styles.js';
|
|
4
4
|
export { safetensorsViewerCss } from './styles.js';
|
|
@@ -11,10 +11,33 @@ export const SAFETENSORS_VIEWER_META = {
|
|
|
11
11
|
optionalServices: ['clipboard'],
|
|
12
12
|
inputOwnership: 'borrows'
|
|
13
13
|
};
|
|
14
|
+
/** Creates a lazy safetensors input backed by browser `Blob.slice()` range reads. */
|
|
15
|
+
export function createSafetensorsBlobSource(blob, fileName = 'model.safetensors') {
|
|
16
|
+
return {
|
|
17
|
+
fileName,
|
|
18
|
+
size: blob.size,
|
|
19
|
+
async read(offset, length, signal) {
|
|
20
|
+
throwIfAborted(signal);
|
|
21
|
+
const bytes = new Uint8Array(await blob.slice(offset, offset + length).arrayBuffer());
|
|
22
|
+
throwIfAborted(signal);
|
|
23
|
+
return bytes;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
14
27
|
export async function mountSafetensorsViewer(input, container, ctx, options = {}) {
|
|
15
28
|
if (options.signal?.aborted)
|
|
16
29
|
throw new MountAbortedError();
|
|
17
|
-
|
|
30
|
+
let document;
|
|
31
|
+
try {
|
|
32
|
+
document = 'data' in input
|
|
33
|
+
? parseSafetensors(input.data)
|
|
34
|
+
: await parseSafetensorsSource(input, { ...(options.signal ? { signal: options.signal } : {}) });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (options.signal?.aborted)
|
|
38
|
+
throw new MountAbortedError();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
18
41
|
if (options.signal?.aborted)
|
|
19
42
|
throw new MountAbortedError();
|
|
20
43
|
return mountSafetensorsDocument(document, input.fileName, container, ctx, options);
|
|
@@ -168,3 +191,8 @@ function element(tag, className, text) {
|
|
|
168
191
|
node.textContent = text;
|
|
169
192
|
return node;
|
|
170
193
|
}
|
|
194
|
+
function throwIfAborted(signal) {
|
|
195
|
+
if (!signal?.aborted)
|
|
196
|
+
return;
|
|
197
|
+
throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-viewer-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -145,6 +145,10 @@
|
|
|
145
145
|
"types": "./dist/parsers/safetensors/index.d.ts",
|
|
146
146
|
"default": "./dist/parsers/safetensors/index.js"
|
|
147
147
|
},
|
|
148
|
+
"./parsers/safetensors/node": {
|
|
149
|
+
"types": "./dist/parsers/safetensors/node.d.ts",
|
|
150
|
+
"default": "./dist/parsers/safetensors/node.js"
|
|
151
|
+
},
|
|
148
152
|
"./viewers/types": {
|
|
149
153
|
"types": "./dist/viewers/types.d.ts",
|
|
150
154
|
"default": "./dist/viewers/types.js"
|