safe-upload 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +134 -2
- package/dist/api.d.ts +25 -0
- package/dist/api.js +172 -0
- package/dist/browser/canvas.d.ts +20 -0
- package/dist/browser/canvas.js +53 -0
- package/dist/browser/faces.d.ts +26 -0
- package/dist/browser/faces.js +68 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/mediapipe.d.ts +28 -0
- package/dist/browser/mediapipe.js +69 -0
- package/dist/browser/objects.d.ts +29 -0
- package/dist/browser/objects.js +69 -0
- package/dist/browser/redact.d.ts +29 -0
- package/dist/browser/redact.js +147 -0
- package/dist/bytes.d.ts +22 -0
- package/dist/bytes.js +109 -0
- package/dist/categories.d.ts +13 -0
- package/dist/categories.js +113 -0
- package/dist/collect.d.ts +16 -0
- package/dist/collect.js +108 -0
- package/dist/exif-tags.d.ts +23 -0
- package/dist/exif-tags.js +200 -0
- package/dist/exif.d.ts +64 -0
- package/dist/exif.js +507 -0
- package/dist/filename.d.ts +19 -0
- package/dist/filename.js +64 -0
- package/dist/heif.d.ts +6 -0
- package/dist/heif.js +299 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/iptc.d.ts +11 -0
- package/dist/iptc.js +118 -0
- package/dist/jpeg.d.ts +51 -0
- package/dist/jpeg.js +374 -0
- package/dist/png.d.ts +9 -0
- package/dist/png.js +245 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +1 -0
- package/dist/webp.d.ts +6 -0
- package/dist/webp.js +123 -0
- package/dist/xmp.d.ts +25 -0
- package/dist/xmp.js +364 -0
- package/package.json +78 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sivaguru Ravi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# safe-upload
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See and remove what a photo reveals **before it leaves the device**: GPS position, capture time and time zone, phone and lens serial numbers, owner names, people tagged in the photo, edit history, embedded previews that still show the uncropped picture, hidden motion-photo video, and AI generation prompts.
|
|
4
|
+
|
|
5
|
+
- **Lossless.** Metadata is cut out of the file; the compressed picture stays byte-for-byte identical. No re-encoding, no quality loss.
|
|
6
|
+
- **Choose what goes.** Everything is grouped into 14 plain-language categories (`location`, `device`, `people`, `hidden`, …). Remove all of them, or only some.
|
|
7
|
+
- **JPEG, PNG, WebP, HEIC and AVIF.** EXIF, XMP, IPTC/Photoshop, PNG text chunks, HEIF items, and data stored after the end of a JPEG.
|
|
8
|
+
- **Zero dependencies, runs anywhere.** Browsers, Web Workers, Node ≥ 18, Deno, Bun. About 20 KB minified and gzipped.
|
|
9
|
+
- **Optional on-device face redaction** (`safe-upload/browser`), using MediaPipe if you install it.
|
|
10
|
+
|
|
11
|
+
Live demo: **https://reveal.shinigami-rog.cc**, which runs this package entirely in your browser.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install safe-upload
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Clean a photo before uploading it
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { strip } from 'safe-upload';
|
|
23
|
+
|
|
24
|
+
input.addEventListener('change', async () => {
|
|
25
|
+
const file = input.files[0];
|
|
26
|
+
const clean = await strip(file); // default: remove everything except camera settings and C2PA credentials
|
|
27
|
+
const body = new FormData();
|
|
28
|
+
body.append('photo', new Blob([clean.bytes], { type: clean.mime }), file.name);
|
|
29
|
+
await fetch('/upload', { method: 'POST', body });
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## See what a photo reveals
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { inspect } from 'safe-upload';
|
|
37
|
+
|
|
38
|
+
const report = await inspect(file);
|
|
39
|
+
report.categories; // ['location', 'people', 'thumbnail', 'device', 'time', ...]
|
|
40
|
+
report.summary.gps; // { latitude: 29.55935, longitude: -95.09065, altitude: 12, direction: 271 }
|
|
41
|
+
report.summary.device; // 'Canon EOS R5'
|
|
42
|
+
report.summary.serialNumbers; // ['DEMO-0123456789']
|
|
43
|
+
report.summary.people; // names tagged in the photo
|
|
44
|
+
report.thumbnails; // embedded preview images (Uint8Array JPEGs)
|
|
45
|
+
report.hidden; // motion-photo video, depth maps, gain maps…
|
|
46
|
+
report.findings; // every item: { category, label, value, source, removable }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Remove only some categories
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const r = await strip(file, { remove: ['location', 'people', 'hidden'] });
|
|
53
|
+
r.removed; // categories that were present and are now gone
|
|
54
|
+
r.retained; // items this format cannot drop in place (e.g. HEIC thumbnails)
|
|
55
|
+
r.notes; // e.g. "The XMP metadata block was removed as a whole."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`remove: 'all'` removes every category, including camera settings and content credentials.
|
|
59
|
+
|
|
60
|
+
### Categories
|
|
61
|
+
|
|
62
|
+
| Category | Removed by default | Examples |
|
|
63
|
+
|----------|:-:|----------|
|
|
64
|
+
| `location` | ✓ | GPS position, altitude, camera direction, city/country fields |
|
|
65
|
+
| `people` | ✓ | People tagged by photo apps (XMP regions, PersonInImage) |
|
|
66
|
+
| `hidden` | ✓ | Motion-photo video, depth maps, HDR gain maps, data after the image |
|
|
67
|
+
| `thumbnail` | ✓ | Embedded previews, which can show the picture before cropping |
|
|
68
|
+
| `author` | ✓ | Artist, owner, copyright, creator contact |
|
|
69
|
+
| `device` | ✓ | Make, model, body and lens serial numbers, maker notes |
|
|
70
|
+
| `time` | ✓ | Capture/edit dates, time zone, GPS timestamp |
|
|
71
|
+
| `ai` | ✓ | Prompts and workflows written by image generators |
|
|
72
|
+
| `descriptions` | ✓ | Captions, keywords, titles, comments |
|
|
73
|
+
| `history` | ✓ | Edit history, document IDs, source documents |
|
|
74
|
+
| `software` | ✓ | Apps used to create or edit the picture |
|
|
75
|
+
| `other` | ✓ | Vendor or unrecognised blocks |
|
|
76
|
+
| `provenance` | | C2PA content credentials |
|
|
77
|
+
| `settings` | | Exposure, aperture, ISO, focal length |
|
|
78
|
+
|
|
79
|
+
Orientation, colour profiles and everything needed to display the picture are always kept.
|
|
80
|
+
|
|
81
|
+
## Check the file name
|
|
82
|
+
|
|
83
|
+
Browsers send the file name with every upload, and camera apps and messengers write the date, device and app into it (`PXL_20241016_103122123.MP.jpg` says: Pixel, 16 Oct 2024 10:31 UTC, motion photo).
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
import { inspectFileName, neutralFileName } from 'safe-upload';
|
|
87
|
+
|
|
88
|
+
inspectFileName('Screenshot_20241016-103122_WhatsApp.jpg').findings;
|
|
89
|
+
// [{ label: 'Screenshot', value: 'Android screenshot' }, { label: 'Taken (from file name)', value: '2024-10-16 10:31:22' }, { label: 'App on screen', value: 'WhatsApp' }]
|
|
90
|
+
|
|
91
|
+
form.append('photo', cleanBlob, neutralFileName(clean.mime)); // "photo.jpg"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Hide faces on-device (browser)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm install safe-upload @mediapipe/tasks-vision
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { detectFaces, redact, pad } from 'safe-upload/browser';
|
|
102
|
+
|
|
103
|
+
const opts = { wasmBaseUrl: '/mediapipe/', modelAssetUrl: '/models/blaze_face_full_range.tflite' }; // self-host both
|
|
104
|
+
const faces = await detectFaces(imageElement, opts);
|
|
105
|
+
const regions = faces.map((f) => pad(f, 0.22, imageElement.naturalWidth, imageElement.naturalHeight));
|
|
106
|
+
const { blob } = await redact(file, regions, { style: 'pixelate', remove: ['location', 'device'] });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Find revealing objects
|
|
110
|
+
|
|
111
|
+
Metadata is only half of it. `detectObjects` (EfficientDet-Lite0, 80 everyday classes, on-device) and `objectHints` point out screens, papers, vehicles, home interiors, street signs and similar things that can identify people or places:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { detectObjects, objectHints } from 'safe-upload/browser';
|
|
115
|
+
|
|
116
|
+
const objects = await detectObjects(imageElement, { wasmBaseUrl: '/mediapipe/', modelAssetUrl: '/models/efficientdet_lite0.tflite' });
|
|
117
|
+
for (const h of objectHints(objects)) console.log(h.title, '-', h.hint); // "1 screen - Screens can show messages, emails, maps…"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`redact` re-encodes the picture (it has to, the pixels change) and copies over only the EXIF categories you keep, with orientation reset to upright. Detection can miss faces, so always show the result and let people add boxes themselves. Copy `vision_wasm_internal.js` and `.wasm` from `@mediapipe/tasks-vision/wasm`, and the model from MediaPipe's model page, to your own server.
|
|
121
|
+
|
|
122
|
+
## How it is tested
|
|
123
|
+
|
|
124
|
+
Every format is checked against two independent tools: **exiftool** must find none of the removed tags in the output, and **ImageMagick's pixel signature** must be identical before and after. A Playwright suite runs the demo site in Chromium, Firefox, WebKit and a mobile profile, and fails if the page makes any request to another origin.
|
|
125
|
+
|
|
126
|
+
## Limits
|
|
127
|
+
|
|
128
|
+
- XMP and IPTC blocks are removed as a whole when any category inside them is removed.
|
|
129
|
+
- The vendor MakerNote is dropped whenever EXIF has to be rewritten, because its internal offsets break when moved.
|
|
130
|
+
- In HEIC and AVIF, embedded thumbnails and auxiliary images (depth, gain map) cannot be removed in place. They are reported in `retained`. Convert the photo to remove them.
|
|
131
|
+
- Metadata hidden in the pixels themselves (watermarks, steganography) is out of scope.
|
|
132
|
+
|
|
133
|
+
## Licence
|
|
134
|
+
|
|
135
|
+
MIT © Sivaguru Ravi
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Category, Format, Input, Report, StripOptions, StripResult } from './types.js';
|
|
2
|
+
export declare function toBytes(input: Input): Promise<Uint8Array>;
|
|
3
|
+
export declare function detectFormat(b: Uint8Array): Format;
|
|
4
|
+
export declare class UnsupportedFormatError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/** Read every metadata block in a photo and describe what it reveals. Never modifies the input. */
|
|
8
|
+
export declare function inspect(input: Input): Promise<Report>;
|
|
9
|
+
/**
|
|
10
|
+
* Remove metadata by category without re-encoding: the compressed picture stays byte-identical.
|
|
11
|
+
* Default removes everything except camera settings and content credentials.
|
|
12
|
+
*/
|
|
13
|
+
export declare function strip(input: Input, options?: StripOptions): Promise<StripResult>;
|
|
14
|
+
/** Convenience: strip and return a Blob ready for FormData/fetch. */
|
|
15
|
+
export declare function stripToBlob(input: Input, options?: StripOptions): Promise<Blob>;
|
|
16
|
+
/**
|
|
17
|
+
* Copy the kept EXIF categories from an original photo into a freshly encoded JPEG or PNG
|
|
18
|
+
* (for example after blurring faces on a canvas). Orientation is reset to 1 because canvas
|
|
19
|
+
* output is already upright; thumbnails are dropped because they no longer match.
|
|
20
|
+
*/
|
|
21
|
+
export declare function carryExif(original: Input, encoded: Input, options: StripOptions & {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}): Promise<Uint8Array>;
|
|
25
|
+
export declare function describeCategory(c: Category): import("./categories.js").CategoryInfo;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { concat, putU16be } from './bytes.js';
|
|
2
|
+
import { CATEGORY_INFO, resolveRemove, sortCategories } from './categories.js';
|
|
3
|
+
import { Collector } from './collect.js';
|
|
4
|
+
import { buildTiff, EXIF_HEADER, parseTiff } from './exif.js';
|
|
5
|
+
import { heifFormat, inspectHeif, stripHeif } from './heif.js';
|
|
6
|
+
import { inspectJpeg, isJpeg, parseJpeg, stripJpeg } from './jpeg.js';
|
|
7
|
+
import { insertPngExif, inspectPng, isPng, stripPng } from './png.js';
|
|
8
|
+
import { inspectWebp, isWebp, stripWebp } from './webp.js';
|
|
9
|
+
const MIME = {
|
|
10
|
+
jpeg: 'image/jpeg',
|
|
11
|
+
png: 'image/png',
|
|
12
|
+
webp: 'image/webp',
|
|
13
|
+
heic: 'image/heic',
|
|
14
|
+
avif: 'image/avif',
|
|
15
|
+
unknown: 'application/octet-stream',
|
|
16
|
+
};
|
|
17
|
+
export async function toBytes(input) {
|
|
18
|
+
if (input instanceof Uint8Array)
|
|
19
|
+
return input;
|
|
20
|
+
if (input instanceof ArrayBuffer)
|
|
21
|
+
return new Uint8Array(input);
|
|
22
|
+
if (typeof Blob !== 'undefined' && input instanceof Blob)
|
|
23
|
+
return new Uint8Array(await input.arrayBuffer());
|
|
24
|
+
throw new TypeError('Expected a Blob, File, ArrayBuffer or Uint8Array');
|
|
25
|
+
}
|
|
26
|
+
export function detectFormat(b) {
|
|
27
|
+
if (isJpeg(b))
|
|
28
|
+
return 'jpeg';
|
|
29
|
+
if (isPng(b))
|
|
30
|
+
return 'png';
|
|
31
|
+
if (isWebp(b))
|
|
32
|
+
return 'webp';
|
|
33
|
+
return heifFormat(b) ?? 'unknown';
|
|
34
|
+
}
|
|
35
|
+
export class UnsupportedFormatError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Unsupported file type. safe-upload reads JPEG, PNG, WebP, HEIC and AVIF.');
|
|
38
|
+
this.name = 'UnsupportedFormatError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Read every metadata block in a photo and describe what it reveals. Never modifies the input. */
|
|
42
|
+
export async function inspect(input) {
|
|
43
|
+
const b = await toBytes(input);
|
|
44
|
+
const format = detectFormat(b);
|
|
45
|
+
const c = new Collector(format, MIME[format], b.length);
|
|
46
|
+
switch (format) {
|
|
47
|
+
case 'jpeg':
|
|
48
|
+
inspectJpeg(b, c);
|
|
49
|
+
break;
|
|
50
|
+
case 'png':
|
|
51
|
+
await inspectPng(b, c);
|
|
52
|
+
break;
|
|
53
|
+
case 'webp':
|
|
54
|
+
inspectWebp(b, c);
|
|
55
|
+
break;
|
|
56
|
+
case 'heic':
|
|
57
|
+
case 'avif':
|
|
58
|
+
inspectHeif(b, c);
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
throw new UnsupportedFormatError();
|
|
62
|
+
}
|
|
63
|
+
return c.finish();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Remove metadata by category without re-encoding: the compressed picture stays byte-identical.
|
|
67
|
+
* Default removes everything except camera settings and content credentials.
|
|
68
|
+
*/
|
|
69
|
+
export async function strip(input, options = {}) {
|
|
70
|
+
const b = await toBytes(input);
|
|
71
|
+
const format = detectFormat(b);
|
|
72
|
+
const remove = resolveRemove(options.remove);
|
|
73
|
+
let r;
|
|
74
|
+
switch (format) {
|
|
75
|
+
case 'jpeg':
|
|
76
|
+
r = stripJpeg(b, remove);
|
|
77
|
+
break;
|
|
78
|
+
case 'png':
|
|
79
|
+
r = await stripPng(b, remove);
|
|
80
|
+
break;
|
|
81
|
+
case 'webp':
|
|
82
|
+
r = stripWebp(b, remove);
|
|
83
|
+
break;
|
|
84
|
+
case 'heic':
|
|
85
|
+
case 'avif':
|
|
86
|
+
r = stripHeif(b, remove);
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
throw new UnsupportedFormatError();
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
bytes: r.bytes,
|
|
93
|
+
mime: MIME[format],
|
|
94
|
+
format,
|
|
95
|
+
lossless: true,
|
|
96
|
+
removed: sortCategories(r.removed),
|
|
97
|
+
retained: r.retained,
|
|
98
|
+
notes: [...new Set(r.notes)],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Convenience: strip and return a Blob ready for FormData/fetch. */
|
|
102
|
+
export async function stripToBlob(input, options = {}) {
|
|
103
|
+
const r = await strip(input, options);
|
|
104
|
+
return new Blob([r.bytes], { type: r.mime });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Copy the kept EXIF categories from an original photo into a freshly encoded JPEG or PNG
|
|
108
|
+
* (for example after blurring faces on a canvas). Orientation is reset to 1 because canvas
|
|
109
|
+
* output is already upright; thumbnails are dropped because they no longer match.
|
|
110
|
+
*/
|
|
111
|
+
export async function carryExif(original, encoded, options) {
|
|
112
|
+
const src = await toBytes(original);
|
|
113
|
+
const dst = await toBytes(encoded);
|
|
114
|
+
const tiffBytes = await extractTiff(src);
|
|
115
|
+
if (!tiffBytes)
|
|
116
|
+
return dst;
|
|
117
|
+
const tiff = parseTiff(tiffBytes);
|
|
118
|
+
if (!tiff)
|
|
119
|
+
return dst;
|
|
120
|
+
const remove = resolveRemove(options.remove);
|
|
121
|
+
remove.add('thumbnail');
|
|
122
|
+
const built = buildTiff(tiff, { remove, orientation: 1, dimensions: { width: options.width, height: options.height } });
|
|
123
|
+
if (!built.bytes)
|
|
124
|
+
return dst;
|
|
125
|
+
if (isJpeg(dst)) {
|
|
126
|
+
const j = parseJpeg(dst);
|
|
127
|
+
// Insert after JFIF APP0 if present, otherwise right after SOI.
|
|
128
|
+
const jfif = j.head.find((s) => s.kind === 'jfif');
|
|
129
|
+
const at = jfif ? jfif.end : 2;
|
|
130
|
+
const payload = concat([EXIF_HEADER, built.bytes]);
|
|
131
|
+
const seg = new Uint8Array(4 + payload.length);
|
|
132
|
+
seg[0] = 0xff;
|
|
133
|
+
seg[1] = 0xe1;
|
|
134
|
+
putU16be(seg, 2, payload.length + 2);
|
|
135
|
+
seg.set(payload, 4);
|
|
136
|
+
return concat([dst.subarray(0, at), seg, dst.subarray(at)]);
|
|
137
|
+
}
|
|
138
|
+
if (isPng(dst))
|
|
139
|
+
return insertPngExif(dst, built.bytes);
|
|
140
|
+
return dst;
|
|
141
|
+
}
|
|
142
|
+
/** Locate the raw TIFF/EXIF block inside a JPEG, PNG, WebP or HEIF file. */
|
|
143
|
+
async function extractTiff(b) {
|
|
144
|
+
const fmt = detectFormat(b);
|
|
145
|
+
if (fmt === 'jpeg') {
|
|
146
|
+
const seg = parseJpeg(b).head.find((s) => s.kind === 'exif');
|
|
147
|
+
return seg ? seg.payload.subarray(6) : null;
|
|
148
|
+
}
|
|
149
|
+
// For other formats reuse inspect-time parsing via a tiny collector.
|
|
150
|
+
const holder = {};
|
|
151
|
+
const c = new Collector(fmt, '', b.length);
|
|
152
|
+
const orig = c.tiff.bind(c);
|
|
153
|
+
c.tiff = (bytes, source, removable) => {
|
|
154
|
+
holder.tiff ??= bytes;
|
|
155
|
+
return orig(bytes, source, removable);
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
if (fmt === 'webp')
|
|
159
|
+
inspectWebp(b, c);
|
|
160
|
+
else if (fmt === 'heic' || fmt === 'avif')
|
|
161
|
+
inspectHeif(b, c);
|
|
162
|
+
else if (fmt === 'png')
|
|
163
|
+
await inspectPng(b, c);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return holder.tiff ?? null;
|
|
169
|
+
}
|
|
170
|
+
export function describeCategory(c) {
|
|
171
|
+
return CATEGORY_INFO[c];
|
|
172
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type AnyCanvas = OffscreenCanvas | HTMLCanvasElement;
|
|
2
|
+
export type Ctx = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
|
3
|
+
/** iOS Safari refuses canvases larger than this many pixels (4096 × 4096). */
|
|
4
|
+
export declare const IOS_CANVAS_LIMIT = 16777216;
|
|
5
|
+
export declare function makeCanvas(width: number, height: number): AnyCanvas;
|
|
6
|
+
export declare function ctx2d(c: AnyCanvas): Ctx;
|
|
7
|
+
export declare function encode(c: AnyCanvas, type: string, quality: number): Promise<Blob>;
|
|
8
|
+
export interface Decoded {
|
|
9
|
+
source: ImageBitmap | HTMLImageElement;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Decode a photo with its EXIF orientation applied, so pixels are upright. Falls back to an
|
|
16
|
+
* <img> element when createImageBitmap refuses very large pictures (seen on mobile Safari).
|
|
17
|
+
*/
|
|
18
|
+
export declare function decodeImage(input: Blob): Promise<Decoded>;
|
|
19
|
+
/** @deprecated use decodeImage */
|
|
20
|
+
export declare function decode(input: Blob): Promise<ImageBitmap>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** iOS Safari refuses canvases larger than this many pixels (4096 × 4096). */
|
|
2
|
+
export const IOS_CANVAS_LIMIT = 16_777_216;
|
|
3
|
+
export function makeCanvas(width, height) {
|
|
4
|
+
if (typeof OffscreenCanvas !== 'undefined')
|
|
5
|
+
return new OffscreenCanvas(width, height);
|
|
6
|
+
const c = document.createElement('canvas');
|
|
7
|
+
c.width = width;
|
|
8
|
+
c.height = height;
|
|
9
|
+
return c;
|
|
10
|
+
}
|
|
11
|
+
export function ctx2d(c) {
|
|
12
|
+
const ctx = c.getContext('2d');
|
|
13
|
+
if (!ctx)
|
|
14
|
+
throw new Error('2D canvas is not available');
|
|
15
|
+
return ctx;
|
|
16
|
+
}
|
|
17
|
+
export async function encode(c, type, quality) {
|
|
18
|
+
if ('convertToBlob' in c)
|
|
19
|
+
return c.convertToBlob({ type, quality });
|
|
20
|
+
return new Promise((resolve, reject) => c.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas encoding failed'))), type, quality));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Decode a photo with its EXIF orientation applied, so pixels are upright. Falls back to an
|
|
24
|
+
* <img> element when createImageBitmap refuses very large pictures (seen on mobile Safari).
|
|
25
|
+
*/
|
|
26
|
+
export async function decodeImage(input) {
|
|
27
|
+
try {
|
|
28
|
+
const b = await createImageBitmap(input, { imageOrientation: 'from-image' });
|
|
29
|
+
return { source: b, width: b.width, height: b.height, close: () => b.close() };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
if (typeof document === 'undefined')
|
|
33
|
+
throw unsupported();
|
|
34
|
+
const url = URL.createObjectURL(input);
|
|
35
|
+
try {
|
|
36
|
+
const img = new Image();
|
|
37
|
+
img.src = url;
|
|
38
|
+
await img.decode();
|
|
39
|
+
return { source: img, width: img.naturalWidth, height: img.naturalHeight, close: () => URL.revokeObjectURL(url) };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
URL.revokeObjectURL(url);
|
|
43
|
+
throw unsupported();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function unsupported() {
|
|
48
|
+
return new Error('This browser cannot decode this image format (HEIC needs Safari). Metadata removal still works.');
|
|
49
|
+
}
|
|
50
|
+
/** @deprecated use decodeImage */
|
|
51
|
+
export async function decode(input) {
|
|
52
|
+
return createImageBitmap(input, { imageOrientation: 'from-image' });
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { visionSupported, VisionUnavailableError, type Box } from './mediapipe.js';
|
|
2
|
+
export type { Box };
|
|
3
|
+
export interface FaceBox extends Box {
|
|
4
|
+
score: number;
|
|
5
|
+
}
|
|
6
|
+
export interface FaceDetectionOptions {
|
|
7
|
+
/** URL of the folder holding MediaPipe's vision_wasm_internal.{js,wasm} (self-host it). */
|
|
8
|
+
wasmBaseUrl: string;
|
|
9
|
+
/** URL of a BlazeFace .tflite model, e.g. blaze_face_full_range.tflite (self-host it). */
|
|
10
|
+
modelAssetUrl: string;
|
|
11
|
+
/** Minimum detection confidence, 0..1. Default 0.4 (favours recall: a missed face is worse). */
|
|
12
|
+
minScore?: number;
|
|
13
|
+
}
|
|
14
|
+
/** @deprecated use visionSupported */
|
|
15
|
+
export declare const faceDetectionSupported: typeof visionSupported;
|
|
16
|
+
export { VisionUnavailableError as FaceDetectionUnavailableError };
|
|
17
|
+
/** Load the detector ahead of time (downloads the WASM runtime and model). */
|
|
18
|
+
export declare function preloadFaceDetector(o: FaceDetectionOptions): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Find faces on-device. Runs on the whole picture and on overlapping tiles so small faces in
|
|
21
|
+
* large group photos are found too. Coordinates are in the source's pixel space.
|
|
22
|
+
*/
|
|
23
|
+
export declare function detectFaces(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement, options: FaceDetectionOptions): Promise<FaceBox[]>;
|
|
24
|
+
export declare function clampBox<T extends Box>(b: T, W: number, H: number): T;
|
|
25
|
+
/** Grow a box by a fraction on each side (faces need margin for hair and ears). */
|
|
26
|
+
export declare function pad(b: Box, fraction: number, maxW: number, maxH: number): Box;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ctx2d, makeCanvas } from './canvas.js';
|
|
2
|
+
import { containment, iou, loadVision, sizeOf, tiles, visionSupported, VisionUnavailableError } from './mediapipe.js';
|
|
3
|
+
let cached;
|
|
4
|
+
/** @deprecated use visionSupported */
|
|
5
|
+
export const faceDetectionSupported = visionSupported;
|
|
6
|
+
export { VisionUnavailableError as FaceDetectionUnavailableError };
|
|
7
|
+
async function getDetector(o) {
|
|
8
|
+
const key = `${o.wasmBaseUrl}|${o.modelAssetUrl}|${o.minScore ?? 0.4}`;
|
|
9
|
+
if (cached?.key === key)
|
|
10
|
+
return cached.detector;
|
|
11
|
+
const detector = (async () => {
|
|
12
|
+
const { vision, fileset } = await loadVision(o.wasmBaseUrl);
|
|
13
|
+
return (await vision.FaceDetector.createFromOptions(fileset, {
|
|
14
|
+
baseOptions: { modelAssetPath: o.modelAssetUrl, delegate: 'CPU' },
|
|
15
|
+
runningMode: 'IMAGE',
|
|
16
|
+
minDetectionConfidence: o.minScore ?? 0.4,
|
|
17
|
+
minSuppressionThreshold: 0.3,
|
|
18
|
+
}));
|
|
19
|
+
})();
|
|
20
|
+
cached = { key, detector };
|
|
21
|
+
detector.catch(() => (cached = undefined));
|
|
22
|
+
return detector;
|
|
23
|
+
}
|
|
24
|
+
/** Load the detector ahead of time (downloads the WASM runtime and model). */
|
|
25
|
+
export async function preloadFaceDetector(o) {
|
|
26
|
+
await getDetector(o);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Find faces on-device. Runs on the whole picture and on overlapping tiles so small faces in
|
|
30
|
+
* large group photos are found too. Coordinates are in the source's pixel space.
|
|
31
|
+
*/
|
|
32
|
+
export async function detectFaces(source, options) {
|
|
33
|
+
const det = await getDetector(options);
|
|
34
|
+
const { W, H } = sizeOf(source);
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const t of tiles(W, H)) {
|
|
37
|
+
const scale = Math.min(1, 1024 / Math.max(t.width, t.height));
|
|
38
|
+
const cw = Math.max(1, Math.round(t.width * scale));
|
|
39
|
+
const ch = Math.max(1, Math.round(t.height * scale));
|
|
40
|
+
const ctx = ctx2d(makeCanvas(cw, ch));
|
|
41
|
+
ctx.drawImage(source, t.x, t.y, t.width, t.height, 0, 0, cw, ch);
|
|
42
|
+
// Raw pixels go straight into WASM memory.
|
|
43
|
+
for (const d of det.detect(ctx.getImageData(0, 0, cw, ch)).detections) {
|
|
44
|
+
const bb = d.boundingBox;
|
|
45
|
+
if (bb)
|
|
46
|
+
found.push({ x: t.x + bb.originX / scale, y: t.y + bb.originY / scale, width: bb.width / scale, height: bb.height / scale, score: d.categories[0]?.score ?? 0 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
found.sort((a, b) => b.score - a.score);
|
|
50
|
+
const kept = [];
|
|
51
|
+
for (const f of found)
|
|
52
|
+
if (!kept.some((k) => iou(k, f) > 0.3 || containment(k, f) > 0.6))
|
|
53
|
+
kept.push(f);
|
|
54
|
+
return kept.map((f) => clampBox(f, W, H));
|
|
55
|
+
}
|
|
56
|
+
export function clampBox(b, W, H) {
|
|
57
|
+
const x = Math.max(0, b.x);
|
|
58
|
+
const y = Math.max(0, b.y);
|
|
59
|
+
return { ...b, x, y, width: Math.min(b.width - (x - b.x), W - x), height: Math.min(b.height - (y - b.y), H - y) };
|
|
60
|
+
}
|
|
61
|
+
/** Grow a box by a fraction on each side (faces need margin for hair and ears). */
|
|
62
|
+
export function pad(b, fraction, maxW, maxH) {
|
|
63
|
+
const dx = b.width * fraction;
|
|
64
|
+
const dy = b.height * fraction;
|
|
65
|
+
const x = Math.max(0, b.x - dx);
|
|
66
|
+
const y = Math.max(0, b.y - dy * 1.4);
|
|
67
|
+
return { x, y, width: Math.min(maxW - x, b.width + 2 * dx), height: Math.min(maxH - y, b.height + dy * 2.4) };
|
|
68
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { detectFaces, preloadFaceDetector, pad, faceDetectionSupported, FaceDetectionUnavailableError } from './faces.js';
|
|
2
|
+
export type { FaceBox, FaceDetectionOptions } from './faces.js';
|
|
3
|
+
export { detectObjects, objectHints } from './objects.js';
|
|
4
|
+
export type { DetectedObject, ObjectDetectionOptions, ObjectHint, HintKind } from './objects.js';
|
|
5
|
+
export { visionSupported, VisionUnavailableError } from './mediapipe.js';
|
|
6
|
+
export type { Box } from './mediapipe.js';
|
|
7
|
+
export { redact } from './redact.js';
|
|
8
|
+
export type { RedactOptions, RedactResult, RedactStyle } from './redact.js';
|
|
9
|
+
export { decode, decodeImage, IOS_CANVAS_LIMIT } from './canvas.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { detectFaces, preloadFaceDetector, pad, faceDetectionSupported, FaceDetectionUnavailableError } from './faces.js';
|
|
2
|
+
export { detectObjects, objectHints } from './objects.js';
|
|
3
|
+
export { visionSupported, VisionUnavailableError } from './mediapipe.js';
|
|
4
|
+
export { redact } from './redact.js';
|
|
5
|
+
export { decode, decodeImage, IOS_CANVAS_LIMIT } from './canvas.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Loads MediaPipe's vision module and WASM runtime once, shared by face and object detection. */
|
|
2
|
+
type Vision = typeof import('@mediapipe/tasks-vision');
|
|
3
|
+
type Fileset = Awaited<ReturnType<Vision['FilesetResolver']['forVisionTasks']>>;
|
|
4
|
+
/** MediaPipe needs WebGL even on its CPU path. False when the browser has WebGL turned off. */
|
|
5
|
+
export declare function visionSupported(): boolean;
|
|
6
|
+
export declare class VisionUnavailableError extends Error {
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
export declare function loadVision(wasmBaseUrl: string): Promise<{
|
|
10
|
+
vision: Vision;
|
|
11
|
+
fileset: Fileset;
|
|
12
|
+
}>;
|
|
13
|
+
export interface Box {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function sizeOf(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement): {
|
|
20
|
+
W: number;
|
|
21
|
+
H: number;
|
|
22
|
+
};
|
|
23
|
+
/** Whole picture plus overlapping tiles, so small things in large photos are found too. */
|
|
24
|
+
export declare function tiles(W: number, H: number): Box[];
|
|
25
|
+
export declare function iou(a: Box, b: Box): number;
|
|
26
|
+
/** Overlap relative to the smaller box, to merge a thing found whole and again in a tile. */
|
|
27
|
+
export declare function containment(a: Box, b: Box): number;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
let cache;
|
|
2
|
+
/** MediaPipe needs WebGL even on its CPU path. False when the browser has WebGL turned off. */
|
|
3
|
+
export function visionSupported() {
|
|
4
|
+
try {
|
|
5
|
+
const c = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(1, 1) : document.createElement('canvas');
|
|
6
|
+
return !!(c.getContext('webgl2') ?? c.getContext('webgl'));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class VisionUnavailableError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super('On-device detection needs WebGL, which is turned off in this browser.');
|
|
15
|
+
this.name = 'VisionUnavailableError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function loadVision(wasmBaseUrl) {
|
|
19
|
+
if (cache?.base === wasmBaseUrl)
|
|
20
|
+
return cache.promise;
|
|
21
|
+
if (!visionSupported())
|
|
22
|
+
throw new VisionUnavailableError();
|
|
23
|
+
const promise = (async () => {
|
|
24
|
+
const vision = await import('@mediapipe/tasks-vision');
|
|
25
|
+
const fileset = await vision.FilesetResolver.forVisionTasks(wasmBaseUrl);
|
|
26
|
+
return { vision, fileset };
|
|
27
|
+
})();
|
|
28
|
+
cache = { base: wasmBaseUrl, promise };
|
|
29
|
+
promise.catch(() => (cache = undefined));
|
|
30
|
+
return promise;
|
|
31
|
+
}
|
|
32
|
+
export function sizeOf(source) {
|
|
33
|
+
return 'naturalWidth' in source ? { W: source.naturalWidth, H: source.naturalHeight } : { W: source.width, H: source.height };
|
|
34
|
+
}
|
|
35
|
+
/** Whole picture plus overlapping tiles, so small things in large photos are found too. */
|
|
36
|
+
export function tiles(W, H) {
|
|
37
|
+
const out = [{ x: 0, y: 0, width: W, height: H }];
|
|
38
|
+
const longest = Math.max(W, H);
|
|
39
|
+
const grids = longest > 2400 ? [2, 3] : longest > 1000 ? [2] : [];
|
|
40
|
+
for (const n of grids) {
|
|
41
|
+
const tw = W / n;
|
|
42
|
+
const th = H / n;
|
|
43
|
+
for (let gy = 0; gy < n; gy++)
|
|
44
|
+
for (let gx = 0; gx < n; gx++) {
|
|
45
|
+
const x = Math.max(0, gx * tw - tw * 0.25);
|
|
46
|
+
const y = Math.max(0, gy * th - th * 0.25);
|
|
47
|
+
out.push({ x, y, width: Math.min(W - x, tw * 1.5), height: Math.min(H - y, th * 1.5) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export function iou(a, b) {
|
|
53
|
+
const x1 = Math.max(a.x, b.x);
|
|
54
|
+
const y1 = Math.max(a.y, b.y);
|
|
55
|
+
const x2 = Math.min(a.x + a.width, b.x + b.width);
|
|
56
|
+
const y2 = Math.min(a.y + a.height, b.y + b.height);
|
|
57
|
+
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
58
|
+
const union = a.width * a.height + b.width * b.height - inter;
|
|
59
|
+
return union > 0 ? inter / union : 0;
|
|
60
|
+
}
|
|
61
|
+
/** Overlap relative to the smaller box, to merge a thing found whole and again in a tile. */
|
|
62
|
+
export function containment(a, b) {
|
|
63
|
+
const x1 = Math.max(a.x, b.x);
|
|
64
|
+
const y1 = Math.max(a.y, b.y);
|
|
65
|
+
const x2 = Math.min(a.x + a.width, b.x + b.width);
|
|
66
|
+
const y2 = Math.min(a.y + a.height, b.y + b.height);
|
|
67
|
+
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
68
|
+
return inter / Math.min(a.width * a.height, b.width * b.height);
|
|
69
|
+
}
|