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 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. Only the header
7
- * is decoded here — the tensor payloads are never read, so inspecting a
8
- * multi-gigabyte checkpoint stays cheap.
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
- const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
33
- const headerLength = Number(view.getBigUint64(0, true));
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 > input.byteLength) {
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 = input.byteLength - headerEnd;
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
- const document = parseSafetensors(input.data);
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.6.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"