rosetta-squint 1.0.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 +97 -0
- package/package.json +59 -0
- package/scripts/squint-cli.mjs +35 -0
- package/src/browser.ts +82 -0
- package/src/index.ts +183 -0
- package/tests/browser-entry.test.ts +72 -0
- package/tests/integration.test.ts +325 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# rosetta-squint — JS/TS
|
|
2
|
+
|
|
3
|
+
Point at a file path or pass raw image bytes; get back the perceptual hash hex string that every other `rosetta-squint` port produces for the same input.
|
|
4
|
+
|
|
5
|
+
## Install (Node)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install rosetta-squint
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { phash, phashBytes } from "rosetta-squint";
|
|
13
|
+
|
|
14
|
+
const h1 = await phash("photo.jpg", 8); // file path
|
|
15
|
+
const h2 = await phashBytes(new Uint8Array(jpegBytes), 8); // raw bytes in memory
|
|
16
|
+
console.log(h1.toString()); // "c3f8a1b27d0e4f96"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install (Browser)
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { phashBytes } from "rosetta-squint/browser";
|
|
23
|
+
|
|
24
|
+
const resp = await fetch("/photo.jpg");
|
|
25
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
26
|
+
const h = await phashBytes(bytes, 8);
|
|
27
|
+
console.log(h.toString()); // "c3f8a1b27d0e4f96"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The `/browser` sub-export omits the path-based functions (which use `node:fs`) and is otherwise identical. All 10 algorithms are available with the same names plus a `Bytes` suffix:
|
|
31
|
+
|
|
32
|
+
`averageHashBytes`, `phashBytes`, `phashSimpleBytes`, `dhashBytes`, `dhashVerticalBytes`, `whashHaarBytes`, `whashDb4Bytes`, `whashDb4RobustBytes`, `colorhashBytes`, `cropResistantHashBytes`.
|
|
33
|
+
|
|
34
|
+
### Via CDN
|
|
35
|
+
|
|
36
|
+
Once on npm:
|
|
37
|
+
|
|
38
|
+
```html
|
|
39
|
+
<script type="module">
|
|
40
|
+
// esm.sh auto-builds browser bundles for any npm package
|
|
41
|
+
import { phashBytes } from "https://esm.sh/rosetta-squint/browser";
|
|
42
|
+
|
|
43
|
+
const resp = await fetch("/photo.jpg");
|
|
44
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
45
|
+
console.log((await phashBytes(bytes, 8)).toString());
|
|
46
|
+
</script>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
esm.sh handles the @jsquash WASM + libheif-js bundling automatically. Other CDN options (`unpkg`, `jsdelivr`) ship the raw `dist/` and assume your bundler handles CJS→ESM for the libheif-js and utif2 transitive deps.
|
|
50
|
+
|
|
51
|
+
### Bundler notes
|
|
52
|
+
|
|
53
|
+
The browser entry uses dynamic `import("libheif-js")` and `import("utif2")` which are CommonJS packages. Modern bundlers handle the CJS→ESM interop automatically:
|
|
54
|
+
|
|
55
|
+
| Bundler | What you need |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Vite | works out of the box |
|
|
58
|
+
| esbuild | works out of the box |
|
|
59
|
+
| webpack 5+ | works out of the box |
|
|
60
|
+
| rollup | add `@rollup/plugin-commonjs` |
|
|
61
|
+
| Parcel | works out of the box |
|
|
62
|
+
|
|
63
|
+
WASM (mozjpeg, libwebp) is loaded via `WebAssembly.compileStreaming(fetch(url))` in the browser. Most bundlers emit the WASM blobs as static assets and rewrite the import URL — no manual config required for Vite/esbuild/webpack 5+.
|
|
64
|
+
|
|
65
|
+
The `loadWasm` helper detects browser vs. Node at runtime and uses the right loader path. The `node:fs` import in the Node branch is dynamic (`await import("node:fs")`) so browser bundlers tree-shake it.
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// Path entry (Node only)
|
|
71
|
+
phash(path: string, hashSize: number): Promise<Hash>
|
|
72
|
+
phashSimple(path: string, hashSize: number): Promise<Hash>
|
|
73
|
+
dhash(path: string, hashSize: number): Promise<Hash>
|
|
74
|
+
dhashVertical(path: string, hashSize: number): Promise<Hash>
|
|
75
|
+
averageHash(path: string, hashSize: number): Promise<Hash>
|
|
76
|
+
whashHaar(path: string, hashSize: number): Promise<Hash>
|
|
77
|
+
whashDb4(path: string, hashSize: number): Promise<Hash>
|
|
78
|
+
whashDb4Robust(path: string, hashSize: number): Promise<Hash>
|
|
79
|
+
colorhash(path: string, binbits: number): Promise<Hash>
|
|
80
|
+
cropResistantHash(path: string): Promise<ImageMultiHash>
|
|
81
|
+
|
|
82
|
+
// Bytes entry (Node + browser)
|
|
83
|
+
phashBytes(bytes: Uint8Array, hashSize: number): Promise<Hash>
|
|
84
|
+
// ... same suffix-Bytes pattern for every algorithm above ...
|
|
85
|
+
|
|
86
|
+
// Lower level
|
|
87
|
+
decodeFile(path: string): Promise<RgbImage> // Node only
|
|
88
|
+
decodeBytes(bytes: Uint8Array): Promise<RgbImage> // Node + browser
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Cross-port verification
|
|
92
|
+
|
|
93
|
+
`phashBytes(imagehash.png, 8) === "ba8c84536bd3c366"` in this package's browser entry, this package's Node entry, the Go squint port, the Java squint port, and the Python `rosetta_squint` port. Same hex everywhere.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
BSD-2-Clause.
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rosetta-squint",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cross-language byte-exact perceptual image hashing — decode + hash in one call. Umbrella package combining rosetta-squint-decode + rosetta-squint-hash.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"perceptual-hashing",
|
|
7
|
+
"imagehash",
|
|
8
|
+
"byte-exact",
|
|
9
|
+
"cross-language",
|
|
10
|
+
"image-decoder",
|
|
11
|
+
"phash"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/wmetcalf/rosetta-squint#readme",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/wmetcalf/rosetta-squint.git",
|
|
17
|
+
"directory": "squint/js/rosetta-squint"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/wmetcalf/rosetta-squint/issues"
|
|
21
|
+
},
|
|
22
|
+
"author": "Will Metcalf <william.metcalf@gmail.com>",
|
|
23
|
+
"license": "BSD-2-Clause",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./browser": {
|
|
33
|
+
"types": "./dist/browser.d.ts",
|
|
34
|
+
"import": "./dist/browser.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"engines": { "node": ">=18" },
|
|
38
|
+
"bin": {
|
|
39
|
+
"squint-cli": "./scripts/squint-cli.mjs"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"rosetta-squint-decode": "file:../../../decode/js/rosetta-squint-decode",
|
|
47
|
+
"rosetta-squint-hash": "file:../../../hash/js/rosetta-squint-hash",
|
|
48
|
+
"@jsquash/jpeg": "^1.6.0",
|
|
49
|
+
"@jsquash/webp": "^1.5.0",
|
|
50
|
+
"libheif-js": "1.17.1",
|
|
51
|
+
"pngjs": "^7",
|
|
52
|
+
"utif2": "^4.1.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^5.5",
|
|
56
|
+
"vitest": "^2",
|
|
57
|
+
"@types/node": "^20"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
phash, phashSimple, dhash, dhashVertical, averageHash,
|
|
4
|
+
whashHaar, whashDb4, whashDb4Robust, colorhash, cropResistantHash,
|
|
5
|
+
} from "../dist/index.js";
|
|
6
|
+
|
|
7
|
+
const [, , algo, sizeStr, path] = process.argv;
|
|
8
|
+
if (!algo || !sizeStr || !path) {
|
|
9
|
+
process.stderr.write("usage: squint-cli <algo> <size> <path>\n");
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
const size = parseInt(sizeStr, 10);
|
|
13
|
+
|
|
14
|
+
const algos = {
|
|
15
|
+
phash, phash_simple: phashSimple, dhash, dhash_vertical: dhashVertical,
|
|
16
|
+
average_hash: averageHash, whash_haar: whashHaar, whash_db4: whashDb4,
|
|
17
|
+
whash_db4_robust: whashDb4Robust, colorhash,
|
|
18
|
+
crop_resistant_hash: cropResistantHash,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const fn = algos[algo];
|
|
22
|
+
if (!fn) {
|
|
23
|
+
process.stderr.write(`unknown algo: ${algo}\n`);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = algo === "crop_resistant_hash"
|
|
29
|
+
? await cropResistantHash(path)
|
|
30
|
+
: await fn(path, size);
|
|
31
|
+
process.stdout.write(`${result.toString()}\n`);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
process.stderr.write(`error: ${e.message ?? e}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry for rosetta-squint.
|
|
3
|
+
*
|
|
4
|
+
* Same convenience API as the main entry except the path-based functions
|
|
5
|
+
* (which read from disk via `node:fs`) are omitted. Use the `_Bytes`
|
|
6
|
+
* variants in browsers; obtain bytes from `fetch(url).then(r => r.arrayBuffer())`,
|
|
7
|
+
* `FileReader`, drag-and-drop, etc.
|
|
8
|
+
*
|
|
9
|
+
* import { phashBytes } from "rosetta-squint/browser";
|
|
10
|
+
*
|
|
11
|
+
* const resp = await fetch("/photo.jpg");
|
|
12
|
+
* const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
13
|
+
* const hash = await phashBytes(bytes, 8);
|
|
14
|
+
* console.log(hash.toString()); // "c3f8a1b27d0e4f96"
|
|
15
|
+
*
|
|
16
|
+
* Underlying decoders (mozjpeg, libwebp, libheif) run in browser via WASM.
|
|
17
|
+
* TIFF (utif2) and HEIC (libheif-js) are CommonJS — your bundler must
|
|
18
|
+
* support CJS→ESM interop (esbuild, vite, webpack 5+, rollup with
|
|
19
|
+
* @rollup/plugin-commonjs all do).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { decode, type DecodedImage } from "rosetta-squint-decode";
|
|
23
|
+
import * as rih from "rosetta-squint-hash/browser";
|
|
24
|
+
|
|
25
|
+
export type { Hash, RgbImage } from "rosetta-squint-hash/browser";
|
|
26
|
+
export type { Format } from "rosetta-squint-decode";
|
|
27
|
+
export {
|
|
28
|
+
ImageMultiHash,
|
|
29
|
+
hexToHash,
|
|
30
|
+
hexToFlathash,
|
|
31
|
+
hexToMultiHash,
|
|
32
|
+
} from "rosetta-squint-hash/browser";
|
|
33
|
+
|
|
34
|
+
function decodedToRgbImage(d: DecodedImage): rih.RgbImage {
|
|
35
|
+
return {
|
|
36
|
+
width: d.width,
|
|
37
|
+
height: d.height,
|
|
38
|
+
data: d.data,
|
|
39
|
+
channels: d.channels,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function decodeBytes(bytes: Uint8Array): Promise<rih.RgbImage> {
|
|
44
|
+
const decoded = await decode(bytes);
|
|
45
|
+
return decodedToRgbImage(decoded);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Bytes-based convenience hash functions (no path variants in browser).
|
|
49
|
+
|
|
50
|
+
export async function averageHashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
51
|
+
return rih.averageHash(await decodeBytes(bytes), hashSize);
|
|
52
|
+
}
|
|
53
|
+
export async function phashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
54
|
+
return rih.phash(await decodeBytes(bytes), hashSize);
|
|
55
|
+
}
|
|
56
|
+
export async function phashSimpleBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
57
|
+
return rih.phashSimple(await decodeBytes(bytes), hashSize);
|
|
58
|
+
}
|
|
59
|
+
export async function dhashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
60
|
+
return rih.dhash(await decodeBytes(bytes), hashSize);
|
|
61
|
+
}
|
|
62
|
+
export async function dhashVerticalBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
63
|
+
return rih.dhashVertical(await decodeBytes(bytes), hashSize);
|
|
64
|
+
}
|
|
65
|
+
export async function whashHaarBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
66
|
+
return rih.whashHaar(await decodeBytes(bytes), hashSize);
|
|
67
|
+
}
|
|
68
|
+
export async function whashDb4Bytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
69
|
+
return rih.whashDb4(await decodeBytes(bytes), hashSize);
|
|
70
|
+
}
|
|
71
|
+
export async function whashDb4RobustBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
72
|
+
return rih.whashDb4Robust(await decodeBytes(bytes), hashSize);
|
|
73
|
+
}
|
|
74
|
+
export async function colorhashBytes(bytes: Uint8Array, binbits: number): Promise<rih.Hash> {
|
|
75
|
+
return rih.colorhash(await decodeBytes(bytes), binbits);
|
|
76
|
+
}
|
|
77
|
+
export async function cropResistantHashBytes(
|
|
78
|
+
bytes: Uint8Array,
|
|
79
|
+
limitSegments?: number,
|
|
80
|
+
) {
|
|
81
|
+
return rih.cropResistantHash(await decodeBytes(bytes), limitSegments);
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { open, lstat } from "node:fs/promises";
|
|
2
|
+
import { decode, type DecodedImage } from "rosetta-squint-decode";
|
|
3
|
+
import * as rih from "rosetta-squint-hash";
|
|
4
|
+
|
|
5
|
+
// Re-export key types and utilities from the underlying packages for ergonomics.
|
|
6
|
+
export type { Hash } from "rosetta-squint-hash";
|
|
7
|
+
export type { Format } from "rosetta-squint-decode";
|
|
8
|
+
export { ImageMultiHash, hexToHash, hexToFlathash, hexToMultiHash } from "rosetta-squint-hash";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Maximum allowed size for path-based decode inputs. Refuse anything larger
|
|
12
|
+
* BEFORE reading bytes. Callers that genuinely need to process images larger
|
|
13
|
+
* than this should decode via rosetta-squint-decode directly after explicit
|
|
14
|
+
* validation.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_FILE_SIZE = 256 * 1024 * 1024; // 256 MiB
|
|
17
|
+
|
|
18
|
+
/** Adapt a rosetta-squint-decode DecodedImage into the rosetta-squint-hash RgbImage shape. */
|
|
19
|
+
function decodedToRgbImage(d: DecodedImage): rih.RgbImage {
|
|
20
|
+
return {
|
|
21
|
+
width: d.width,
|
|
22
|
+
height: d.height,
|
|
23
|
+
data: d.data,
|
|
24
|
+
channels: d.channels,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decode raw bytes (any supported format) into the rgb image shape used by the hash lib. */
|
|
29
|
+
export async function decodeBytes(bytes: Uint8Array): Promise<rih.RgbImage> {
|
|
30
|
+
const decoded = await decode(bytes);
|
|
31
|
+
return decodedToRgbImage(decoded);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a file from disk and decode.
|
|
35
|
+
*
|
|
36
|
+
* Refuses symlinks (via `lstat`), non-regular files (FIFOs, /dev/zero,
|
|
37
|
+
* character devices, etc.) and files larger than MAX_FILE_SIZE BEFORE
|
|
38
|
+
* reading bytes — without these guards `readFile("/dev/zero")` would loop
|
|
39
|
+
* until OOM and a 300 MiB sparse file would allocate 300 MiB even though
|
|
40
|
+
* it contains no image. Callers who genuinely want symlink resolution
|
|
41
|
+
* must do it explicitly (e.g. `fs.promises.realpath`) before calling
|
|
42
|
+
* this function.
|
|
43
|
+
*
|
|
44
|
+
* Node's `fs.open` doesn't directly expose `O_NOFOLLOW`, so we lstat the
|
|
45
|
+
* path first and reject symlinks. The window between the lstat and the
|
|
46
|
+
* subsequent open is narrow — the attacker would have to swap the target
|
|
47
|
+
* between those two syscalls, much harder than swapping the symlink
|
|
48
|
+
* destination across an unrelated stat→read window.
|
|
49
|
+
*
|
|
50
|
+
* The file is then opened ONCE via `fs.open`, and `fhandle.stat()` plus
|
|
51
|
+
* `fhandle.read()` operate on the same fd. `fhandle.stat()` ultimately
|
|
52
|
+
* calls fstat(2) on the open descriptor, not stat(2) on the path, which
|
|
53
|
+
* closes the TOCTOU window between the size check and the read. The read
|
|
54
|
+
* is bounded by `MAX_FILE_SIZE + 1` so a concurrent writer that grows the
|
|
55
|
+
* file after the size check is still rejected.
|
|
56
|
+
*/
|
|
57
|
+
export async function decodeFile(path: string): Promise<rih.RgbImage> {
|
|
58
|
+
// Lstat-then-open is unfortunately not atomic on Node — there's no
|
|
59
|
+
// public API for O_NOFOLLOW. The race is much narrower than the
|
|
60
|
+
// stat→read race we already close below.
|
|
61
|
+
const linkStat = await lstat(path);
|
|
62
|
+
if (linkStat.isSymbolicLink()) {
|
|
63
|
+
throw new TypeError(`symlink not allowed: ${path}`);
|
|
64
|
+
}
|
|
65
|
+
const fh = await open(path, "r");
|
|
66
|
+
try {
|
|
67
|
+
const st = await fh.stat();
|
|
68
|
+
if (!st.isFile()) {
|
|
69
|
+
throw new Error(`not a regular file: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
if (st.size > MAX_FILE_SIZE) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`input file too large: ${st.size} bytes (max ${MAX_FILE_SIZE} `
|
|
74
|
+
+ `bytes / 256 MiB). For images above this threshold, decode `
|
|
75
|
+
+ `via rosetta-squint-decode directly after explicit validation.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// Read up to MAX_FILE_SIZE+1 bytes so a concurrent writer that
|
|
79
|
+
// grows the file post-stat is rejected rather than silently
|
|
80
|
+
// exceeding the cap.
|
|
81
|
+
const cap = Math.min(st.size, MAX_FILE_SIZE) + 1;
|
|
82
|
+
const buf = new Uint8Array(cap);
|
|
83
|
+
let total = 0;
|
|
84
|
+
while (total < cap) {
|
|
85
|
+
const { bytesRead } = await fh.read(
|
|
86
|
+
buf, total, cap - total, total,
|
|
87
|
+
);
|
|
88
|
+
if (bytesRead === 0) break;
|
|
89
|
+
total += bytesRead;
|
|
90
|
+
}
|
|
91
|
+
if (total > MAX_FILE_SIZE) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`input file too large: ${total} bytes (max ${MAX_FILE_SIZE} `
|
|
94
|
+
+ `bytes / 256 MiB). For images above this threshold, decode `
|
|
95
|
+
+ `via rosetta-squint-decode directly after explicit validation.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const bytes = buf.subarray(0, total);
|
|
99
|
+
return decodeBytes(bytes);
|
|
100
|
+
} finally {
|
|
101
|
+
await fh.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Convenience hash functions — each accepts either a file path or raw bytes.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export async function averageHash(path: string, hashSize: number): Promise<rih.Hash> {
|
|
110
|
+
return rih.averageHash(await decodeFile(path), hashSize);
|
|
111
|
+
}
|
|
112
|
+
export async function averageHashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
113
|
+
return rih.averageHash(await decodeBytes(bytes), hashSize);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function phash(path: string, hashSize: number): Promise<rih.Hash> {
|
|
117
|
+
return rih.phash(await decodeFile(path), hashSize);
|
|
118
|
+
}
|
|
119
|
+
export async function phashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
120
|
+
return rih.phash(await decodeBytes(bytes), hashSize);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function phashSimple(path: string, hashSize: number): Promise<rih.Hash> {
|
|
124
|
+
return rih.phashSimple(await decodeFile(path), hashSize);
|
|
125
|
+
}
|
|
126
|
+
export async function phashSimpleBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
127
|
+
return rih.phashSimple(await decodeBytes(bytes), hashSize);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function dhash(path: string, hashSize: number): Promise<rih.Hash> {
|
|
131
|
+
return rih.dhash(await decodeFile(path), hashSize);
|
|
132
|
+
}
|
|
133
|
+
export async function dhashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
134
|
+
return rih.dhash(await decodeBytes(bytes), hashSize);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function dhashVertical(path: string, hashSize: number): Promise<rih.Hash> {
|
|
138
|
+
return rih.dhashVertical(await decodeFile(path), hashSize);
|
|
139
|
+
}
|
|
140
|
+
export async function dhashVerticalBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
141
|
+
return rih.dhashVertical(await decodeBytes(bytes), hashSize);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function whashHaar(path: string, hashSize: number): Promise<rih.Hash> {
|
|
145
|
+
return rih.whashHaar(await decodeFile(path), hashSize);
|
|
146
|
+
}
|
|
147
|
+
export async function whashHaarBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
148
|
+
return rih.whashHaar(await decodeBytes(bytes), hashSize);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function whashDb4(path: string, hashSize: number): Promise<rih.Hash> {
|
|
152
|
+
return rih.whashDb4(await decodeFile(path), hashSize);
|
|
153
|
+
}
|
|
154
|
+
export async function whashDb4Bytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
155
|
+
return rih.whashDb4(await decodeBytes(bytes), hashSize);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function whashDb4Robust(path: string, hashSize: number): Promise<rih.Hash> {
|
|
159
|
+
return rih.whashDb4Robust(await decodeFile(path), hashSize);
|
|
160
|
+
}
|
|
161
|
+
export async function whashDb4RobustBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
|
|
162
|
+
return rih.whashDb4Robust(await decodeBytes(bytes), hashSize);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function colorhash(path: string, binbits: number): Promise<rih.Hash> {
|
|
166
|
+
return rih.colorhash(await decodeFile(path), binbits);
|
|
167
|
+
}
|
|
168
|
+
export async function colorhashBytes(bytes: Uint8Array, binbits: number): Promise<rih.Hash> {
|
|
169
|
+
return rih.colorhash(await decodeBytes(bytes), binbits);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function cropResistantHash(
|
|
173
|
+
path: string,
|
|
174
|
+
limitSegments?: number,
|
|
175
|
+
): Promise<rih.ImageMultiHash> {
|
|
176
|
+
return rih.cropResistantHash(await decodeFile(path), limitSegments);
|
|
177
|
+
}
|
|
178
|
+
export async function cropResistantHashBytes(
|
|
179
|
+
bytes: Uint8Array,
|
|
180
|
+
limitSegments?: number,
|
|
181
|
+
): Promise<rih.ImageMultiHash> {
|
|
182
|
+
return rih.cropResistantHash(await decodeBytes(bytes), limitSegments);
|
|
183
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-entry smoke test for rosetta-squint.
|
|
3
|
+
*
|
|
4
|
+
* Imports `rosetta-squint/browser` and verifies:
|
|
5
|
+
* 1. All bytes-based convenience functions are exported and callable
|
|
6
|
+
* 2. Path-based functions are NOT exported (no `node:fs`)
|
|
7
|
+
* 3. Hash output matches the main entry for the same byte input
|
|
8
|
+
* (no behavioral drift between entries)
|
|
9
|
+
*
|
|
10
|
+
* vitest runs this in Node, so we can't directly verify browser execution,
|
|
11
|
+
* but the import-tree check + dynamic-import-of-node:fs branch logic in
|
|
12
|
+
* loadWasm.ts means the same dist/browser.js bundle that loads here will
|
|
13
|
+
* load in browsers when fetched via CDN / served by a bundler.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import * as browserEntry from "../src/browser.js";
|
|
19
|
+
import * as mainEntry from "../src/index.js";
|
|
20
|
+
|
|
21
|
+
const FIXTURES = new URL("../../../../hash/spec/fixtures/", import.meta.url);
|
|
22
|
+
|
|
23
|
+
describe("rosetta-squint/browser entry", () => {
|
|
24
|
+
it("exposes bytes-based functions for all 10 algorithms", () => {
|
|
25
|
+
expect(typeof browserEntry.averageHashBytes).toBe("function");
|
|
26
|
+
expect(typeof browserEntry.phashBytes).toBe("function");
|
|
27
|
+
expect(typeof browserEntry.phashSimpleBytes).toBe("function");
|
|
28
|
+
expect(typeof browserEntry.dhashBytes).toBe("function");
|
|
29
|
+
expect(typeof browserEntry.dhashVerticalBytes).toBe("function");
|
|
30
|
+
expect(typeof browserEntry.whashHaarBytes).toBe("function");
|
|
31
|
+
expect(typeof browserEntry.whashDb4Bytes).toBe("function");
|
|
32
|
+
expect(typeof browserEntry.whashDb4RobustBytes).toBe("function");
|
|
33
|
+
expect(typeof browserEntry.colorhashBytes).toBe("function");
|
|
34
|
+
expect(typeof browserEntry.cropResistantHashBytes).toBe("function");
|
|
35
|
+
expect(typeof browserEntry.decodeBytes).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does NOT expose path-based functions (no node:fs)", () => {
|
|
39
|
+
// @ts-expect-error — phash (path) is intentionally not exported from /browser
|
|
40
|
+
expect(browserEntry.phash).toBeUndefined();
|
|
41
|
+
// @ts-expect-error
|
|
42
|
+
expect(browserEntry.decodeFile).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("phashBytes on imagehash.png matches main entry's phash", async () => {
|
|
46
|
+
const path = new URL("imagehash.png", FIXTURES);
|
|
47
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
48
|
+
const hBrowser = await browserEntry.phashBytes(bytes, 8);
|
|
49
|
+
const hMain = await mainEntry.phashBytes(bytes, 8);
|
|
50
|
+
expect(hBrowser.toString()).toBe(hMain.toString());
|
|
51
|
+
// And matches the cross-port reference value from Go/Java/JS reports
|
|
52
|
+
expect(hBrowser.toString()).toBe("ba8c84536bd3c366");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("dhashBytes on imagehash.png produces non-empty hex", async () => {
|
|
56
|
+
const path = new URL("imagehash.png", FIXTURES);
|
|
57
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
58
|
+
const h = await browserEntry.dhashBytes(bytes, 8);
|
|
59
|
+
expect(h.toString()).toMatch(/^[0-9a-f]+$/);
|
|
60
|
+
expect(h.toString().length).toBe(16);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("decodeBytes produces an RgbImage shape", async () => {
|
|
64
|
+
const path = new URL("imagehash.png", FIXTURES);
|
|
65
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
66
|
+
const img = await browserEntry.decodeBytes(bytes);
|
|
67
|
+
expect(img.width).toBeGreaterThan(0);
|
|
68
|
+
expect(img.height).toBeGreaterThan(0);
|
|
69
|
+
expect(img.channels === 3 || img.channels === 4).toBe(true);
|
|
70
|
+
expect(img.data.byteLength).toBe(img.width * img.height * img.channels);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
phash, phashBytes,
|
|
7
|
+
averageHash, averageHashBytes,
|
|
8
|
+
dhash, dhashBytes,
|
|
9
|
+
dhashVertical, dhashVerticalBytes,
|
|
10
|
+
phashSimple, phashSimpleBytes,
|
|
11
|
+
whashHaar, whashHaarBytes,
|
|
12
|
+
whashDb4, whashDb4Bytes,
|
|
13
|
+
colorhash, colorhashBytes,
|
|
14
|
+
cropResistantHash, cropResistantHashBytes,
|
|
15
|
+
decodeFile,
|
|
16
|
+
hexToHash, hexToFlathash, hexToMultiHash,
|
|
17
|
+
ImageMultiHash,
|
|
18
|
+
} from "../src/index.js";
|
|
19
|
+
import * as rih from "rosetta-squint-hash";
|
|
20
|
+
|
|
21
|
+
const FIXTURES_PNG = join(
|
|
22
|
+
new URL(".", import.meta.url).pathname,
|
|
23
|
+
"../../../../decode/spec/fixtures/png/valid",
|
|
24
|
+
);
|
|
25
|
+
const FIXTURES_JPEG = join(
|
|
26
|
+
new URL(".", import.meta.url).pathname,
|
|
27
|
+
"../../../../decode/spec/fixtures/jpeg/valid",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const PNG_FILES = [
|
|
31
|
+
"imagehash.png",
|
|
32
|
+
"peppers.png",
|
|
33
|
+
"checker-256.png",
|
|
34
|
+
];
|
|
35
|
+
const JPEG_FILES = [
|
|
36
|
+
"larger-photo-128.jpg",
|
|
37
|
+
"64x64-quality-50.jpg",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// phash
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
describe("phash", () => {
|
|
44
|
+
for (const name of PNG_FILES) {
|
|
45
|
+
const path = join(FIXTURES_PNG, name);
|
|
46
|
+
|
|
47
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
48
|
+
const h = await phash(path, 8);
|
|
49
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
50
|
+
expect(h.toHex().length).toBe(16); // 8*8 bits = 64 bits = 16 hex chars
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it(`phash === phashBytes for ${name}`, async () => {
|
|
54
|
+
const [hPath, hBytes] = await Promise.all([
|
|
55
|
+
phash(path, 8),
|
|
56
|
+
readFile(path).then(b => phashBytes(new Uint8Array(b), 8)),
|
|
57
|
+
]);
|
|
58
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it(`phash matches rih.phash(decodeFile) for ${name}`, async () => {
|
|
62
|
+
const [hSquint, img] = await Promise.all([
|
|
63
|
+
phash(path, 8),
|
|
64
|
+
decodeFile(path),
|
|
65
|
+
]);
|
|
66
|
+
const hRih = rih.phash(img, 8);
|
|
67
|
+
expect(hSquint.toHex()).toBe(hRih.toHex());
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const name of JPEG_FILES) {
|
|
72
|
+
const path = join(FIXTURES_JPEG, name);
|
|
73
|
+
|
|
74
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
75
|
+
const h = await phash(path, 8);
|
|
76
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it(`phash === phashBytes for ${name}`, async () => {
|
|
80
|
+
const [hPath, hBytes] = await Promise.all([
|
|
81
|
+
phash(path, 8),
|
|
82
|
+
readFile(path).then(b => phashBytes(new Uint8Array(b), 8)),
|
|
83
|
+
]);
|
|
84
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// averageHash
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
describe("averageHash", () => {
|
|
93
|
+
for (const name of PNG_FILES) {
|
|
94
|
+
const path = join(FIXTURES_PNG, name);
|
|
95
|
+
|
|
96
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
97
|
+
const h = await averageHash(path, 8);
|
|
98
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it(`averageHash === averageHashBytes for ${name}`, async () => {
|
|
102
|
+
const [hPath, hBytes] = await Promise.all([
|
|
103
|
+
averageHash(path, 8),
|
|
104
|
+
readFile(path).then(b => averageHashBytes(new Uint8Array(b), 8)),
|
|
105
|
+
]);
|
|
106
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it(`averageHash matches rih.averageHash(decodeFile) for ${name}`, async () => {
|
|
110
|
+
const [hSquint, img] = await Promise.all([
|
|
111
|
+
averageHash(path, 8),
|
|
112
|
+
decodeFile(path),
|
|
113
|
+
]);
|
|
114
|
+
const hRih = rih.averageHash(img, 8);
|
|
115
|
+
expect(hSquint.toHex()).toBe(hRih.toHex());
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// dhash
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
describe("dhash", () => {
|
|
124
|
+
for (const name of ["imagehash.png", "checker-256.png"]) {
|
|
125
|
+
const path = join(FIXTURES_PNG, name);
|
|
126
|
+
|
|
127
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
128
|
+
const h = await dhash(path, 8);
|
|
129
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it(`dhash === dhashBytes for ${name}`, async () => {
|
|
133
|
+
const [hPath, hBytes] = await Promise.all([
|
|
134
|
+
dhash(path, 8),
|
|
135
|
+
readFile(path).then(b => dhashBytes(new Uint8Array(b), 8)),
|
|
136
|
+
]);
|
|
137
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// dhashVertical
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
describe("dhashVertical", () => {
|
|
146
|
+
for (const name of ["imagehash.png", "checker-256.png"]) {
|
|
147
|
+
const path = join(FIXTURES_PNG, name);
|
|
148
|
+
|
|
149
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
150
|
+
const h = await dhashVertical(path, 8);
|
|
151
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it(`dhashVertical === dhashVerticalBytes for ${name}`, async () => {
|
|
155
|
+
const [hPath, hBytes] = await Promise.all([
|
|
156
|
+
dhashVertical(path, 8),
|
|
157
|
+
readFile(path).then(b => dhashVerticalBytes(new Uint8Array(b), 8)),
|
|
158
|
+
]);
|
|
159
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// phashSimple
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
describe("phashSimple", () => {
|
|
168
|
+
for (const name of ["imagehash.png", "peppers.png"]) {
|
|
169
|
+
const path = join(FIXTURES_PNG, name);
|
|
170
|
+
|
|
171
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
172
|
+
const h = await phashSimple(path, 8);
|
|
173
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it(`phashSimple === phashSimpleBytes for ${name}`, async () => {
|
|
177
|
+
const [hPath, hBytes] = await Promise.all([
|
|
178
|
+
phashSimple(path, 8),
|
|
179
|
+
readFile(path).then(b => phashSimpleBytes(new Uint8Array(b), 8)),
|
|
180
|
+
]);
|
|
181
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// whashHaar
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
describe("whashHaar", () => {
|
|
190
|
+
for (const name of ["imagehash.png", "peppers.png"]) {
|
|
191
|
+
const path = join(FIXTURES_PNG, name);
|
|
192
|
+
|
|
193
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
194
|
+
const h = await whashHaar(path, 8);
|
|
195
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it(`whashHaar === whashHaarBytes for ${name}`, async () => {
|
|
199
|
+
const [hPath, hBytes] = await Promise.all([
|
|
200
|
+
whashHaar(path, 8),
|
|
201
|
+
readFile(path).then(b => whashHaarBytes(new Uint8Array(b), 8)),
|
|
202
|
+
]);
|
|
203
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// whashDb4
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
describe("whashDb4", () => {
|
|
212
|
+
for (const name of ["imagehash.png", "peppers.png"]) {
|
|
213
|
+
const path = join(FIXTURES_PNG, name);
|
|
214
|
+
|
|
215
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
216
|
+
const h = await whashDb4(path, 8);
|
|
217
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it(`whashDb4 === whashDb4Bytes for ${name}`, async () => {
|
|
221
|
+
const [hPath, hBytes] = await Promise.all([
|
|
222
|
+
whashDb4(path, 8),
|
|
223
|
+
readFile(path).then(b => whashDb4Bytes(new Uint8Array(b), 8)),
|
|
224
|
+
]);
|
|
225
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// colorhash
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
describe("colorhash", () => {
|
|
234
|
+
for (const name of ["imagehash.png", "peppers.png"]) {
|
|
235
|
+
const path = join(FIXTURES_PNG, name);
|
|
236
|
+
|
|
237
|
+
it(`returns a Hash for ${name}`, async () => {
|
|
238
|
+
const h = await colorhash(path, 3);
|
|
239
|
+
expect(h).toBeInstanceOf(rih.Hash);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it(`colorhash === colorhashBytes for ${name}`, async () => {
|
|
243
|
+
const [hPath, hBytes] = await Promise.all([
|
|
244
|
+
colorhash(path, 3),
|
|
245
|
+
readFile(path).then(b => colorhashBytes(new Uint8Array(b), 3)),
|
|
246
|
+
]);
|
|
247
|
+
expect(hPath.toHex()).toBe(hBytes.toHex());
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it(`colorhash matches rih.colorhash(decodeFile) for ${name}`, async () => {
|
|
251
|
+
const [hSquint, img] = await Promise.all([
|
|
252
|
+
colorhash(path, 3),
|
|
253
|
+
decodeFile(path),
|
|
254
|
+
]);
|
|
255
|
+
const hRih = rih.colorhash(img, 3);
|
|
256
|
+
expect(hSquint.toHex()).toBe(hRih.toHex());
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// cropResistantHash
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
describe("cropResistantHash", () => {
|
|
265
|
+
for (const name of ["imagehash.png", "peppers.png"]) {
|
|
266
|
+
const path = join(FIXTURES_PNG, name);
|
|
267
|
+
|
|
268
|
+
it(`returns an ImageMultiHash for ${name}`, async () => {
|
|
269
|
+
const mh = await cropResistantHash(path);
|
|
270
|
+
expect(mh).toBeInstanceOf(ImageMultiHash);
|
|
271
|
+
expect(mh.segmentHashes.length).toBeGreaterThan(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it(`cropResistantHash === cropResistantHashBytes for ${name}`, async () => {
|
|
275
|
+
const [mhPath, mhBytes] = await Promise.all([
|
|
276
|
+
cropResistantHash(path),
|
|
277
|
+
readFile(path).then(b => cropResistantHashBytes(new Uint8Array(b))),
|
|
278
|
+
]);
|
|
279
|
+
expect(mhPath.toString()).toBe(mhBytes.toString());
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it(`cropResistantHash matches rih.cropResistantHash(decodeFile) for ${name}`, async () => {
|
|
283
|
+
const [mhSquint, img] = await Promise.all([
|
|
284
|
+
cropResistantHash(path),
|
|
285
|
+
decodeFile(path),
|
|
286
|
+
]);
|
|
287
|
+
const mhRih = rih.cropResistantHash(img);
|
|
288
|
+
expect(mhSquint.toString()).toBe(mhRih.toString());
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Re-export sanity checks
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
describe("re-exports", () => {
|
|
297
|
+
it("hexToHash round-trips a 16-char hex string", () => {
|
|
298
|
+
const hex = "ffffffffffffffff";
|
|
299
|
+
const h = hexToHash(hex);
|
|
300
|
+
expect(h.toHex()).toBe(hex);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("hexToFlathash round-trips a colorhash string", async () => {
|
|
304
|
+
const path = join(FIXTURES_PNG, "imagehash.png");
|
|
305
|
+
const h = await colorhash(path, 3);
|
|
306
|
+
const hex = h.toHex();
|
|
307
|
+
const h2 = hexToFlathash(hex, 3);
|
|
308
|
+
expect(h2.toHex()).toBe(hex);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("hexToMultiHash round-trips a cropResistantHash string", async () => {
|
|
312
|
+
const path = join(FIXTURES_PNG, "imagehash.png");
|
|
313
|
+
const mh = await cropResistantHash(path);
|
|
314
|
+
const str = mh.toString();
|
|
315
|
+
const mh2 = hexToMultiHash(str);
|
|
316
|
+
expect(mh2.toString()).toBe(str);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("ImageMultiHash is exported and constructible", () => {
|
|
320
|
+
const bits = [[true, false], [false, true]];
|
|
321
|
+
const h = new rih.Hash(bits);
|
|
322
|
+
const mh = new ImageMultiHash([h]);
|
|
323
|
+
expect(mh.segmentHashes.length).toBe(1);
|
|
324
|
+
});
|
|
325
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"noUncheckedIndexedAccess": false
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"]
|
|
16
|
+
}
|