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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Box } from './mediapipe.js';
|
|
2
|
+
export interface DetectedObject extends Box {
|
|
3
|
+
/** COCO class name, e.g. "laptop", "car", "person". */
|
|
4
|
+
label: string;
|
|
5
|
+
score: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ObjectDetectionOptions {
|
|
8
|
+
/** Folder holding MediaPipe's vision_wasm_internal.{js,wasm} (self-host it). */
|
|
9
|
+
wasmBaseUrl: string;
|
|
10
|
+
/** EfficientDet-Lite .tflite model (self-host it). */
|
|
11
|
+
modelAssetUrl: string;
|
|
12
|
+
/** Minimum confidence, 0..1. Default 0.35. */
|
|
13
|
+
minScore?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Find everyday objects on-device (80 COCO classes). Coordinates are in the source's pixel space. */
|
|
16
|
+
export declare function detectObjects(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement, options: ObjectDetectionOptions): Promise<DetectedObject[]>;
|
|
17
|
+
export type HintKind = 'screen' | 'document' | 'vehicle' | 'people' | 'home' | 'street' | 'travel' | 'time' | 'pet';
|
|
18
|
+
export interface ObjectHint {
|
|
19
|
+
kind: HintKind;
|
|
20
|
+
/** e.g. "2 screens" */
|
|
21
|
+
title: string;
|
|
22
|
+
/** What it can give away, in plain words. */
|
|
23
|
+
hint: string;
|
|
24
|
+
/** True when hiding the area is a sensible suggestion. */
|
|
25
|
+
redactable: boolean;
|
|
26
|
+
objects: DetectedObject[];
|
|
27
|
+
}
|
|
28
|
+
/** Turn detected objects into privacy hints, most important first. */
|
|
29
|
+
export declare function objectHints(objects: DetectedObject[]): ObjectHint[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ctx2d, makeCanvas } from './canvas.js';
|
|
2
|
+
import { clampBox } from './faces.js';
|
|
3
|
+
import { containment, iou, loadVision, sizeOf, tiles } from './mediapipe.js';
|
|
4
|
+
let cached;
|
|
5
|
+
async function getDetector(o) {
|
|
6
|
+
const key = `${o.wasmBaseUrl}|${o.modelAssetUrl}|${o.minScore ?? 0.35}`;
|
|
7
|
+
if (cached?.key === key)
|
|
8
|
+
return cached.detector;
|
|
9
|
+
const detector = (async () => {
|
|
10
|
+
const { vision, fileset } = await loadVision(o.wasmBaseUrl);
|
|
11
|
+
return (await vision.ObjectDetector.createFromOptions(fileset, {
|
|
12
|
+
baseOptions: { modelAssetPath: o.modelAssetUrl, delegate: 'CPU' },
|
|
13
|
+
runningMode: 'IMAGE',
|
|
14
|
+
scoreThreshold: o.minScore ?? 0.35,
|
|
15
|
+
maxResults: 40,
|
|
16
|
+
}));
|
|
17
|
+
})();
|
|
18
|
+
cached = { key, detector };
|
|
19
|
+
detector.catch(() => (cached = undefined));
|
|
20
|
+
return detector;
|
|
21
|
+
}
|
|
22
|
+
/** Find everyday objects on-device (80 COCO classes). Coordinates are in the source's pixel space. */
|
|
23
|
+
export async function detectObjects(source, options) {
|
|
24
|
+
const det = await getDetector(options);
|
|
25
|
+
const { W, H } = sizeOf(source);
|
|
26
|
+
const found = [];
|
|
27
|
+
for (const t of tiles(W, H)) {
|
|
28
|
+
const scale = Math.min(1, 1024 / Math.max(t.width, t.height));
|
|
29
|
+
const cw = Math.max(1, Math.round(t.width * scale));
|
|
30
|
+
const ch = Math.max(1, Math.round(t.height * scale));
|
|
31
|
+
const ctx = ctx2d(makeCanvas(cw, ch));
|
|
32
|
+
ctx.drawImage(source, t.x, t.y, t.width, t.height, 0, 0, cw, ch);
|
|
33
|
+
for (const d of det.detect(ctx.getImageData(0, 0, cw, ch)).detections) {
|
|
34
|
+
const bb = d.boundingBox;
|
|
35
|
+
const c = d.categories[0];
|
|
36
|
+
if (bb && c)
|
|
37
|
+
found.push({ label: c.categoryName, score: c.score, x: t.x + bb.originX / scale, y: t.y + bb.originY / scale, width: bb.width / scale, height: bb.height / scale });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
found.sort((a, b) => b.score - a.score);
|
|
41
|
+
const kept = [];
|
|
42
|
+
for (const f of found)
|
|
43
|
+
if (!kept.some((k) => k.label === f.label && (iou(k, f) > 0.45 || containment(k, f) > 0.75)))
|
|
44
|
+
kept.push(f);
|
|
45
|
+
return kept.map((f) => clampBox(f, W, H));
|
|
46
|
+
}
|
|
47
|
+
/** Per-group confidence bars: lower where a miss is costly (screens, people), higher where false alarms are likely. */
|
|
48
|
+
const GROUPS = [
|
|
49
|
+
{ kind: 'screen', labels: ['laptop', 'tv', 'cell phone'], one: 'screen', many: 'screens', hint: 'Screens can show messages, emails, maps, names or an address. Zoom in and hide anything readable.', redactable: true, min: 0.45 },
|
|
50
|
+
{ kind: 'document', labels: ['book'], one: 'book or printed page', many: 'books or printed pages', hint: 'Letters, labels and printed pages can carry names, addresses and account numbers.', redactable: true, min: 0.55 },
|
|
51
|
+
{ kind: 'vehicle', labels: ['car', 'truck', 'bus', 'motorcycle'], one: 'vehicle', many: 'vehicles', hint: 'Number plates identify a vehicle and its owner, and their format gives away the country or state.', redactable: true, min: 0.45 },
|
|
52
|
+
{ kind: 'people', labels: ['person'], one: 'person', many: 'people', hint: 'Faces are covered by the face scan, but tattoos, uniforms, name badges and school logos can still identify people.', redactable: false, min: 0.4 },
|
|
53
|
+
{ kind: 'home', labels: ['bed', 'couch', 'dining table', 'toilet', 'refrigerator', 'oven', 'microwave', 'sink', 'potted plant'], one: 'household item', many: 'household items', hint: 'This looks like the inside of a home. Rooms and the view from a window are often enough to find an address.', redactable: false, min: 0.55 },
|
|
54
|
+
{ kind: 'street', labels: ['stop sign', 'traffic light', 'fire hydrant', 'parking meter', 'bench'], one: 'street sign or fixture', many: 'street signs and fixtures', hint: 'Street signs and fixtures differ by country and city; together with buildings they can pin down the street.', redactable: false, min: 0.5 },
|
|
55
|
+
{ kind: 'travel', labels: ['airplane', 'train', 'boat', 'suitcase'], one: 'travel item', many: 'travel items', hint: 'Travel scenes show that you are away from home.', redactable: false, min: 0.55 },
|
|
56
|
+
{ kind: 'time', labels: ['clock'], one: 'clock', many: 'clocks', hint: 'A visible clock shows the time the photo was taken.', redactable: false, min: 0.55 },
|
|
57
|
+
{ kind: 'pet', labels: ['dog', 'cat', 'bird', 'horse'], one: 'pet or animal', many: 'pets or animals', hint: "Pets' names are common password and security-question answers; avoid naming them in the caption.", redactable: false, min: 0.6 },
|
|
58
|
+
];
|
|
59
|
+
/** Turn detected objects into privacy hints, most important first. */
|
|
60
|
+
export function objectHints(objects) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const g of GROUPS) {
|
|
63
|
+
const objs = objects.filter((o) => g.labels.includes(o.label) && o.score >= g.min);
|
|
64
|
+
if (!objs.length)
|
|
65
|
+
continue;
|
|
66
|
+
out.push({ kind: g.kind, title: `${objs.length} ${objs.length === 1 ? g.one : g.many}`, hint: g.hint, redactable: g.redactable, objects: objs });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Input, StripOptions } from '../types.js';
|
|
2
|
+
import type { Box } from './mediapipe.js';
|
|
3
|
+
export type RedactStyle = 'pixelate' | 'blur' | 'solid';
|
|
4
|
+
export interface RedactOptions extends StripOptions {
|
|
5
|
+
style?: RedactStyle;
|
|
6
|
+
/** Output type. Defaults to JPEG, or PNG when the input is PNG. */
|
|
7
|
+
type?: 'image/jpeg' | 'image/png' | 'image/webp';
|
|
8
|
+
quality?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Largest canvas area used at once. Bigger JPEG photos are processed in strips and encoded with
|
|
11
|
+
* @jsquash/jpeg (WASM), keeping full resolution. Default: 16,777,216, the iOS Safari limit.
|
|
12
|
+
*/
|
|
13
|
+
maxCanvasPixels?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface RedactResult {
|
|
16
|
+
blob: Blob;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
/** The picture was processed in strips because it exceeds maxCanvasPixels. */
|
|
20
|
+
tiled: boolean;
|
|
21
|
+
/** The output is smaller than the original because the browser could not handle full size. */
|
|
22
|
+
downscaled: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hide regions (faces, plates, screens) and re-encode the photo on-device. Kept EXIF categories
|
|
26
|
+
* are copied into the new file; everything else is gone. Region coordinates are in the upright
|
|
27
|
+
* picture's pixel space (as shown by an <img> element).
|
|
28
|
+
*/
|
|
29
|
+
export declare function redact(input: Input, regions: Box[], options?: RedactOptions): Promise<RedactResult>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { carryExif, toBytes } from '../api.js';
|
|
2
|
+
import { ctx2d, decodeImage, encode, IOS_CANVAS_LIMIT, makeCanvas } from './canvas.js';
|
|
3
|
+
function shape(ctx, b) {
|
|
4
|
+
ctx.beginPath();
|
|
5
|
+
const r = Math.min(b.width, b.height) * 0.3;
|
|
6
|
+
if ('roundRect' in ctx)
|
|
7
|
+
ctx.roundRect(b.x, b.y, b.width, b.height, r);
|
|
8
|
+
else
|
|
9
|
+
ctx.rect(b.x, b.y, b.width, b.height);
|
|
10
|
+
}
|
|
11
|
+
/** Hide one area of a canvas in place. */
|
|
12
|
+
function applyRegion(ctx, canvas, b, style) {
|
|
13
|
+
const x = Math.floor(b.x);
|
|
14
|
+
const y = Math.floor(b.y);
|
|
15
|
+
const w = Math.max(1, Math.ceil(b.width));
|
|
16
|
+
const h = Math.max(1, Math.ceil(b.height));
|
|
17
|
+
ctx.save();
|
|
18
|
+
shape(ctx, { x, y, width: w, height: h });
|
|
19
|
+
ctx.clip();
|
|
20
|
+
if (style === 'solid') {
|
|
21
|
+
ctx.fillStyle = '#000';
|
|
22
|
+
ctx.fillRect(x, y, w, h);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Downscaling destroys detail irreversibly; upscale either blocky (pixelate) or smooth (blur).
|
|
26
|
+
const factor = style === 'pixelate' ? Math.max(8, Math.round(Math.max(w, h) / 7)) : Math.max(12, Math.round(Math.max(w, h) / 5));
|
|
27
|
+
const sw = Math.max(1, Math.round(w / factor));
|
|
28
|
+
const sh = Math.max(1, Math.round(h / factor));
|
|
29
|
+
const small = makeCanvas(sw, sh);
|
|
30
|
+
const sctx = ctx2d(small);
|
|
31
|
+
sctx.imageSmoothingEnabled = true;
|
|
32
|
+
sctx.drawImage(canvas, x, y, w, h, 0, 0, sw, sh);
|
|
33
|
+
ctx.imageSmoothingEnabled = style === 'blur';
|
|
34
|
+
if (style === 'blur')
|
|
35
|
+
ctx.imageSmoothingQuality = 'high';
|
|
36
|
+
ctx.drawImage(small, 0, 0, sw, sh, x, y, w, h);
|
|
37
|
+
}
|
|
38
|
+
ctx.restore();
|
|
39
|
+
}
|
|
40
|
+
/** Whole-picture path: one canvas. Used when the picture fits the canvas limit. */
|
|
41
|
+
async function singleCanvas(img, regions, style, type, quality, scale = 1) {
|
|
42
|
+
const width = Math.max(1, Math.round(img.width * scale));
|
|
43
|
+
const height = Math.max(1, Math.round(img.height * scale));
|
|
44
|
+
const canvas = makeCanvas(width, height);
|
|
45
|
+
const ctx = ctx2d(canvas);
|
|
46
|
+
ctx.drawImage(img.source, 0, 0, width, height);
|
|
47
|
+
for (const r of regions)
|
|
48
|
+
applyRegion(ctx, canvas, { x: r.x * scale, y: r.y * scale, width: r.width * scale, height: r.height * scale }, style);
|
|
49
|
+
const blob = await encode(canvas, type, quality);
|
|
50
|
+
return { bytes: new Uint8Array(await blob.arrayBuffer()), width, height };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Strip path: render each hidden area once as a patch, then build the picture strip by strip
|
|
54
|
+
* (each strip within the canvas limit), copy the pixels into one buffer and encode with mozjpeg.
|
|
55
|
+
*/
|
|
56
|
+
async function strips(img, regions, style, quality, max) {
|
|
57
|
+
const { width: W, height: H } = img;
|
|
58
|
+
const { default: encodeJpeg } = await import('@jsquash/jpeg/encode');
|
|
59
|
+
const patches = regions.map((r) => {
|
|
60
|
+
const rx = Math.max(0, Math.floor(r.x));
|
|
61
|
+
const ry = Math.max(0, Math.floor(r.y));
|
|
62
|
+
const rw = Math.min(W - rx, Math.ceil(r.width));
|
|
63
|
+
const rh = Math.min(H - ry, Math.ceil(r.height));
|
|
64
|
+
const s = Math.min(1, Math.sqrt(max / Math.max(1, rw * rh)));
|
|
65
|
+
const pw = Math.max(1, Math.round(rw * s));
|
|
66
|
+
const ph = Math.max(1, Math.round(rh * s));
|
|
67
|
+
const canvas = makeCanvas(pw, ph);
|
|
68
|
+
const ctx = ctx2d(canvas);
|
|
69
|
+
ctx.drawImage(img.source, rx, ry, rw, rh, 0, 0, pw, ph);
|
|
70
|
+
applyRegion(ctx, canvas, { x: 0, y: 0, width: pw, height: ph }, style);
|
|
71
|
+
return { canvas, rx, ry, rw, rh, pw, ph };
|
|
72
|
+
});
|
|
73
|
+
const stripH = Math.max(1, Math.min(H, Math.floor(max / W)));
|
|
74
|
+
const data = new Uint8ClampedArray(W * H * 4);
|
|
75
|
+
let canvas = makeCanvas(W, stripH);
|
|
76
|
+
let ctx = ctx2d(canvas);
|
|
77
|
+
for (let y = 0; y < H; y += stripH) {
|
|
78
|
+
const h = Math.min(stripH, H - y);
|
|
79
|
+
if (h !== canvas.height) {
|
|
80
|
+
canvas = makeCanvas(W, h);
|
|
81
|
+
ctx = ctx2d(canvas);
|
|
82
|
+
}
|
|
83
|
+
ctx.clearRect(0, 0, W, h);
|
|
84
|
+
ctx.drawImage(img.source, 0, y, W, h, 0, 0, W, h);
|
|
85
|
+
for (const p of patches) {
|
|
86
|
+
if (p.ry >= y + h || p.ry + p.rh <= y)
|
|
87
|
+
continue;
|
|
88
|
+
ctx.imageSmoothingEnabled = true;
|
|
89
|
+
ctx.drawImage(p.canvas, 0, 0, p.pw, p.ph, p.rx, p.ry - y, p.rw, p.rh);
|
|
90
|
+
}
|
|
91
|
+
data.set(ctx.getImageData(0, 0, W, h).data, y * W * 4);
|
|
92
|
+
}
|
|
93
|
+
const out = await encodeJpeg({ data, width: W, height: H, colorSpace: 'srgb' }, { quality: Math.round(quality * 100) });
|
|
94
|
+
return { bytes: new Uint8Array(out), width: W, height: H };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Hide regions (faces, plates, screens) and re-encode the photo on-device. Kept EXIF categories
|
|
98
|
+
* are copied into the new file; everything else is gone. Region coordinates are in the upright
|
|
99
|
+
* picture's pixel space (as shown by an <img> element).
|
|
100
|
+
*/
|
|
101
|
+
export async function redact(input, regions, options = {}) {
|
|
102
|
+
const bytes = await toBytes(input);
|
|
103
|
+
const blobIn = input instanceof Blob ? input : new Blob([bytes]);
|
|
104
|
+
const img = await decodeImage(blobIn);
|
|
105
|
+
const style = options.style ?? 'pixelate';
|
|
106
|
+
const quality = options.quality ?? 0.92;
|
|
107
|
+
const isPng = bytes[0] === 0x89 && bytes[1] === 0x50;
|
|
108
|
+
const type = options.type ?? (isPng ? 'image/png' : 'image/jpeg');
|
|
109
|
+
const max = options.maxCanvasPixels ?? IOS_CANVAS_LIMIT;
|
|
110
|
+
let result;
|
|
111
|
+
let tiled = false;
|
|
112
|
+
let downscaled = false;
|
|
113
|
+
try {
|
|
114
|
+
if (img.width * img.height <= max) {
|
|
115
|
+
try {
|
|
116
|
+
result = await singleCanvas(img, regions, style, type, quality);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (type !== 'image/jpeg')
|
|
120
|
+
throw err;
|
|
121
|
+
// The browser refused a canvas this size even though it is under the limit: use strips.
|
|
122
|
+
result = await strips(img, regions, style, quality, Math.min(max, IOS_CANVAS_LIMIT / 4));
|
|
123
|
+
tiled = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (type === 'image/jpeg') {
|
|
127
|
+
try {
|
|
128
|
+
result = await strips(img, regions, style, quality, max);
|
|
129
|
+
tiled = true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Encoder missing or out of memory: fall back to the largest picture the canvas allows.
|
|
133
|
+
result = await singleCanvas(img, regions, style, type, quality, Math.sqrt(max / (img.width * img.height)) * 0.999);
|
|
134
|
+
downscaled = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
result = await singleCanvas(img, regions, style, type, quality, Math.sqrt(max / (img.width * img.height)) * 0.999);
|
|
139
|
+
downscaled = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
img.close();
|
|
144
|
+
}
|
|
145
|
+
const merged = type === 'image/webp' ? result.bytes : await carryExif(bytes, result.bytes, { remove: options.remove, width: result.width, height: result.height });
|
|
146
|
+
return { blob: new Blob([merged], { type }), width: result.width, height: result.height, tiled, downscaled };
|
|
147
|
+
}
|
package/dist/bytes.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Small byte helpers shared by every format parser. No platform APIs beyond TextDecoder. */
|
|
2
|
+
export declare function u16be(b: Uint8Array, o: number): number;
|
|
3
|
+
export declare function u32be(b: Uint8Array, o: number): number;
|
|
4
|
+
export declare function u16le(b: Uint8Array, o: number): number;
|
|
5
|
+
export declare function u32le(b: Uint8Array, o: number): number;
|
|
6
|
+
export declare function u64be(b: Uint8Array, o: number): number;
|
|
7
|
+
export declare function putU16be(b: Uint8Array, o: number, v: number): void;
|
|
8
|
+
export declare function putU32be(b: Uint8Array, o: number, v: number): void;
|
|
9
|
+
export declare function putU32le(b: Uint8Array, o: number, v: number): void;
|
|
10
|
+
/** Latin-1 decode; safe for 4CCs, marker identifiers and ASCII tag values. */
|
|
11
|
+
export declare function latin1(b: Uint8Array, start?: number, end?: number): string;
|
|
12
|
+
export declare function utf8(b: Uint8Array): string;
|
|
13
|
+
export declare function encodeAscii(s: string): Uint8Array;
|
|
14
|
+
export declare function startsWith(b: Uint8Array, offset: number, ascii: string): boolean;
|
|
15
|
+
export declare function indexOfAscii(b: Uint8Array, ascii: string, from?: number, to?: number): number;
|
|
16
|
+
export declare function concat(parts: Uint8Array[]): Uint8Array;
|
|
17
|
+
/** Trim trailing NULs and whitespace from a decoded string. */
|
|
18
|
+
export declare function clean(s: string): string;
|
|
19
|
+
export declare function crc32(b: Uint8Array, start?: number, end?: number): number;
|
|
20
|
+
/** zlib inflate via the Compression Streams API (browsers, workers, Node >= 18, Deno, Bun). */
|
|
21
|
+
export declare function inflate(data: Uint8Array): Promise<Uint8Array>;
|
|
22
|
+
export declare function toHex(n: number, width?: number): string;
|
package/dist/bytes.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Small byte helpers shared by every format parser. No platform APIs beyond TextDecoder. */
|
|
2
|
+
export function u16be(b, o) {
|
|
3
|
+
return (b[o] << 8) | b[o + 1];
|
|
4
|
+
}
|
|
5
|
+
export function u32be(b, o) {
|
|
6
|
+
return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
|
|
7
|
+
}
|
|
8
|
+
export function u16le(b, o) {
|
|
9
|
+
return b[o] | (b[o + 1] << 8);
|
|
10
|
+
}
|
|
11
|
+
export function u32le(b, o) {
|
|
12
|
+
return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
|
|
13
|
+
}
|
|
14
|
+
export function u64be(b, o) {
|
|
15
|
+
return u32be(b, o) * 0x1_0000_0000 + u32be(b, o + 4);
|
|
16
|
+
}
|
|
17
|
+
export function putU16be(b, o, v) {
|
|
18
|
+
b[o] = (v >>> 8) & 0xff;
|
|
19
|
+
b[o + 1] = v & 0xff;
|
|
20
|
+
}
|
|
21
|
+
export function putU32be(b, o, v) {
|
|
22
|
+
b[o] = (v >>> 24) & 0xff;
|
|
23
|
+
b[o + 1] = (v >>> 16) & 0xff;
|
|
24
|
+
b[o + 2] = (v >>> 8) & 0xff;
|
|
25
|
+
b[o + 3] = v & 0xff;
|
|
26
|
+
}
|
|
27
|
+
export function putU32le(b, o, v) {
|
|
28
|
+
b[o] = v & 0xff;
|
|
29
|
+
b[o + 1] = (v >>> 8) & 0xff;
|
|
30
|
+
b[o + 2] = (v >>> 16) & 0xff;
|
|
31
|
+
b[o + 3] = (v >>> 24) & 0xff;
|
|
32
|
+
}
|
|
33
|
+
/** Latin-1 decode; safe for 4CCs, marker identifiers and ASCII tag values. */
|
|
34
|
+
export function latin1(b, start = 0, end = b.length) {
|
|
35
|
+
let s = '';
|
|
36
|
+
for (let i = start; i < end; i++)
|
|
37
|
+
s += String.fromCharCode(b[i]);
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: false });
|
|
41
|
+
export function utf8(b) {
|
|
42
|
+
return utf8Decoder.decode(b);
|
|
43
|
+
}
|
|
44
|
+
export function encodeAscii(s) {
|
|
45
|
+
const out = new Uint8Array(s.length);
|
|
46
|
+
for (let i = 0; i < s.length; i++)
|
|
47
|
+
out[i] = s.charCodeAt(i) & 0xff;
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function startsWith(b, offset, ascii) {
|
|
51
|
+
if (offset < 0 || offset + ascii.length > b.length)
|
|
52
|
+
return false;
|
|
53
|
+
for (let i = 0; i < ascii.length; i++)
|
|
54
|
+
if (b[offset + i] !== ascii.charCodeAt(i))
|
|
55
|
+
return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
export function indexOfAscii(b, ascii, from = 0, to = b.length) {
|
|
59
|
+
const first = ascii.charCodeAt(0);
|
|
60
|
+
const last = Math.min(to, b.length) - ascii.length;
|
|
61
|
+
for (let i = from; i <= last; i++) {
|
|
62
|
+
if (b[i] === first && startsWith(b, i, ascii))
|
|
63
|
+
return i;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
export function concat(parts) {
|
|
68
|
+
let len = 0;
|
|
69
|
+
for (const p of parts)
|
|
70
|
+
len += p.length;
|
|
71
|
+
const out = new Uint8Array(len);
|
|
72
|
+
let o = 0;
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
out.set(p, o);
|
|
75
|
+
o += p.length;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Trim trailing NULs and whitespace from a decoded string. */
|
|
80
|
+
export function clean(s) {
|
|
81
|
+
return s.replace(/[\u0000\s]+$/u, '').replace(/^\s+/u, '');
|
|
82
|
+
}
|
|
83
|
+
let crcTable;
|
|
84
|
+
export function crc32(b, start = 0, end = b.length) {
|
|
85
|
+
if (!crcTable) {
|
|
86
|
+
crcTable = new Uint32Array(256);
|
|
87
|
+
for (let n = 0; n < 256; n++) {
|
|
88
|
+
let c = n;
|
|
89
|
+
for (let k = 0; k < 8; k++)
|
|
90
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
91
|
+
crcTable[n] = c >>> 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let c = 0xffffffff;
|
|
95
|
+
for (let i = start; i < end; i++)
|
|
96
|
+
c = crcTable[(c ^ b[i]) & 0xff] ^ (c >>> 8);
|
|
97
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
98
|
+
}
|
|
99
|
+
/** zlib inflate via the Compression Streams API (browsers, workers, Node >= 18, Deno, Bun). */
|
|
100
|
+
export async function inflate(data) {
|
|
101
|
+
const DS = globalThis.DecompressionStream;
|
|
102
|
+
if (!DS)
|
|
103
|
+
throw new Error('DecompressionStream is not available in this runtime');
|
|
104
|
+
const stream = new Blob([data]).stream().pipeThrough(new DS('deflate'));
|
|
105
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
106
|
+
}
|
|
107
|
+
export function toHex(n, width = 4) {
|
|
108
|
+
return '0x' + n.toString(16).toUpperCase().padStart(width, '0');
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Category, Risk } from './types.js';
|
|
2
|
+
export interface CategoryInfo {
|
|
3
|
+
label: string;
|
|
4
|
+
risk: Risk;
|
|
5
|
+
description: string;
|
|
6
|
+
defaultRemove: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Display order, most revealing first. */
|
|
9
|
+
export declare const CATEGORY_ORDER: Category[];
|
|
10
|
+
export declare const CATEGORY_INFO: Record<Category, CategoryInfo>;
|
|
11
|
+
export declare const DEFAULT_REMOVE: Category[];
|
|
12
|
+
export declare function resolveRemove(remove: Category[] | 'all' | undefined): Set<Category>;
|
|
13
|
+
export declare function sortCategories(set: Iterable<Category>): Category[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Display order, most revealing first. */
|
|
2
|
+
export const CATEGORY_ORDER = [
|
|
3
|
+
'location',
|
|
4
|
+
'people',
|
|
5
|
+
'hidden',
|
|
6
|
+
'thumbnail',
|
|
7
|
+
'author',
|
|
8
|
+
'device',
|
|
9
|
+
'time',
|
|
10
|
+
'ai',
|
|
11
|
+
'descriptions',
|
|
12
|
+
'history',
|
|
13
|
+
'software',
|
|
14
|
+
'other',
|
|
15
|
+
'provenance',
|
|
16
|
+
'settings',
|
|
17
|
+
];
|
|
18
|
+
export const CATEGORY_INFO = {
|
|
19
|
+
location: {
|
|
20
|
+
label: 'Location',
|
|
21
|
+
risk: 'high',
|
|
22
|
+
description: 'GPS coordinates and place names of where the photo was taken.',
|
|
23
|
+
defaultRemove: true,
|
|
24
|
+
},
|
|
25
|
+
people: {
|
|
26
|
+
label: 'Named people',
|
|
27
|
+
risk: 'high',
|
|
28
|
+
description: 'Names of people tagged in the photo by photo apps.',
|
|
29
|
+
defaultRemove: true,
|
|
30
|
+
},
|
|
31
|
+
hidden: {
|
|
32
|
+
label: 'Hidden extra media',
|
|
33
|
+
risk: 'high',
|
|
34
|
+
description: 'Extra pictures or video stored after the main image, such as motion-photo clips, depth maps and HDR gain maps.',
|
|
35
|
+
defaultRemove: true,
|
|
36
|
+
},
|
|
37
|
+
thumbnail: {
|
|
38
|
+
label: 'Embedded preview',
|
|
39
|
+
risk: 'high',
|
|
40
|
+
description: 'A small copy of the picture stored inside the file. After cropping or editing it can still show the original.',
|
|
41
|
+
defaultRemove: true,
|
|
42
|
+
},
|
|
43
|
+
author: {
|
|
44
|
+
label: 'Names & copyright',
|
|
45
|
+
risk: 'high',
|
|
46
|
+
description: 'Owner, artist or photographer names, copyright notices and contact details.',
|
|
47
|
+
defaultRemove: true,
|
|
48
|
+
},
|
|
49
|
+
device: {
|
|
50
|
+
label: 'Camera & device',
|
|
51
|
+
risk: 'medium',
|
|
52
|
+
description: 'Phone or camera make and model, lens, and serial numbers that link photos to one device.',
|
|
53
|
+
defaultRemove: true,
|
|
54
|
+
},
|
|
55
|
+
time: {
|
|
56
|
+
label: 'Date & time',
|
|
57
|
+
risk: 'medium',
|
|
58
|
+
description: 'When the photo was taken or edited, including your time zone.',
|
|
59
|
+
defaultRemove: true,
|
|
60
|
+
},
|
|
61
|
+
ai: {
|
|
62
|
+
label: 'AI generation data',
|
|
63
|
+
risk: 'medium',
|
|
64
|
+
description: 'Prompts, settings and workflows saved by image generators.',
|
|
65
|
+
defaultRemove: true,
|
|
66
|
+
},
|
|
67
|
+
descriptions: {
|
|
68
|
+
label: 'Titles & comments',
|
|
69
|
+
risk: 'medium',
|
|
70
|
+
description: 'Captions, keywords, titles, ratings and comments written into the file.',
|
|
71
|
+
defaultRemove: true,
|
|
72
|
+
},
|
|
73
|
+
history: {
|
|
74
|
+
label: 'Edit history',
|
|
75
|
+
risk: 'medium',
|
|
76
|
+
description: 'Editing steps, editor settings and IDs of the documents this image was made from.',
|
|
77
|
+
defaultRemove: true,
|
|
78
|
+
},
|
|
79
|
+
software: {
|
|
80
|
+
label: 'Software',
|
|
81
|
+
risk: 'low',
|
|
82
|
+
description: 'Apps and versions used to create or edit the photo.',
|
|
83
|
+
defaultRemove: true,
|
|
84
|
+
},
|
|
85
|
+
other: {
|
|
86
|
+
label: 'Other metadata',
|
|
87
|
+
risk: 'low',
|
|
88
|
+
description: 'Vendor-specific or unrecognised metadata blocks.',
|
|
89
|
+
defaultRemove: true,
|
|
90
|
+
},
|
|
91
|
+
provenance: {
|
|
92
|
+
label: 'Content credentials',
|
|
93
|
+
risk: 'low',
|
|
94
|
+
description: 'C2PA provenance data describing how the image was made. Removing it is allowed but breaks verification.',
|
|
95
|
+
defaultRemove: false,
|
|
96
|
+
},
|
|
97
|
+
settings: {
|
|
98
|
+
label: 'Camera settings',
|
|
99
|
+
risk: 'low',
|
|
100
|
+
description: 'Exposure, aperture, ISO and focal length. Rarely sensitive.',
|
|
101
|
+
defaultRemove: false,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
export const DEFAULT_REMOVE = CATEGORY_ORDER.filter((c) => CATEGORY_INFO[c].defaultRemove);
|
|
105
|
+
export function resolveRemove(remove) {
|
|
106
|
+
if (remove === 'all')
|
|
107
|
+
return new Set(CATEGORY_ORDER);
|
|
108
|
+
return new Set(remove ?? DEFAULT_REMOVE);
|
|
109
|
+
}
|
|
110
|
+
export function sortCategories(set) {
|
|
111
|
+
const s = new Set(set);
|
|
112
|
+
return CATEGORY_ORDER.filter((c) => s.has(c));
|
|
113
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Tiff } from './exif.js';
|
|
2
|
+
import type { Category, Finding, Format, HiddenMedia, Report, Source } from './types.js';
|
|
3
|
+
/** Accumulates findings from every metadata block of one file into a Report. */
|
|
4
|
+
export declare class Collector {
|
|
5
|
+
findings: Finding[];
|
|
6
|
+
report: Report;
|
|
7
|
+
constructor(format: Format, mime: string, size: number);
|
|
8
|
+
add(f: Finding): void;
|
|
9
|
+
note(s: string): void;
|
|
10
|
+
hidden(h: HiddenMedia): void;
|
|
11
|
+
tiff(bytes: Uint8Array, source: Source, removable?: boolean): Tiff | null;
|
|
12
|
+
xmp(bytes: Uint8Array | string, source: Source, removable?: boolean): Set<Category>;
|
|
13
|
+
irb(bytes: Uint8Array, source: Source, removable?: boolean): Set<Category>;
|
|
14
|
+
finish(): Report;
|
|
15
|
+
}
|
|
16
|
+
export declare function categoriesOf(findings: Finding[]): Set<Category>;
|
package/dist/collect.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { utf8 } from './bytes.js';
|
|
2
|
+
import { sortCategories } from './categories.js';
|
|
3
|
+
import { analyzeTiff, parseTiff } from './exif.js';
|
|
4
|
+
import { analyzeIrb } from './iptc.js';
|
|
5
|
+
import { analyzeXmp, xmpFindings } from './xmp.js';
|
|
6
|
+
/** Accumulates findings from every metadata block of one file into a Report. */
|
|
7
|
+
export class Collector {
|
|
8
|
+
findings = [];
|
|
9
|
+
report;
|
|
10
|
+
constructor(format, mime, size) {
|
|
11
|
+
this.report = {
|
|
12
|
+
format,
|
|
13
|
+
mime,
|
|
14
|
+
size,
|
|
15
|
+
findings: this.findings,
|
|
16
|
+
categories: [],
|
|
17
|
+
summary: { serialNumbers: [], people: [], names: [], software: [] },
|
|
18
|
+
thumbnails: [],
|
|
19
|
+
hidden: [],
|
|
20
|
+
notes: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
add(f) {
|
|
24
|
+
this.findings.push(f);
|
|
25
|
+
}
|
|
26
|
+
note(s) {
|
|
27
|
+
if (!this.report.notes.includes(s))
|
|
28
|
+
this.report.notes.push(s);
|
|
29
|
+
}
|
|
30
|
+
hidden(h) {
|
|
31
|
+
this.report.hidden.push(h);
|
|
32
|
+
}
|
|
33
|
+
tiff(bytes, source, removable = true) {
|
|
34
|
+
const tiff = parseTiff(bytes);
|
|
35
|
+
if (!tiff) {
|
|
36
|
+
this.add({ category: 'other', source, key: 'exif.unreadable', label: 'Unreadable EXIF block', value: `${bytes.length} bytes`, removable });
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const a = analyzeTiff(tiff, source, removable);
|
|
40
|
+
this.findings.push(...a.findings);
|
|
41
|
+
const s = this.report.summary;
|
|
42
|
+
if (a.gps && !s.gps)
|
|
43
|
+
s.gps = a.gps;
|
|
44
|
+
if (a.capturedAt && !s.capturedAt)
|
|
45
|
+
s.capturedAt = a.capturedAt;
|
|
46
|
+
if (a.timeZone && !s.timeZone)
|
|
47
|
+
s.timeZone = a.timeZone;
|
|
48
|
+
if (a.device && !s.device)
|
|
49
|
+
s.device = a.device;
|
|
50
|
+
s.serialNumbers.push(...a.serialNumbers);
|
|
51
|
+
s.names.push(...a.names);
|
|
52
|
+
s.software.push(...a.software);
|
|
53
|
+
if (a.orientation && !this.report.orientation)
|
|
54
|
+
this.report.orientation = a.orientation;
|
|
55
|
+
if (a.thumbnail)
|
|
56
|
+
this.report.thumbnails.push(a.thumbnail);
|
|
57
|
+
if (!this.report.width && a.width && a.height) {
|
|
58
|
+
this.report.width = a.width;
|
|
59
|
+
this.report.height = a.height;
|
|
60
|
+
}
|
|
61
|
+
for (const w of tiff.warnings.slice(0, 3))
|
|
62
|
+
this.note(`EXIF: ${w}`);
|
|
63
|
+
return tiff;
|
|
64
|
+
}
|
|
65
|
+
xmp(bytes, source, removable = true) {
|
|
66
|
+
const text = typeof bytes === 'string' ? bytes : utf8(bytes);
|
|
67
|
+
const a = analyzeXmp(text);
|
|
68
|
+
this.findings.push(...xmpFindings(a, source, removable));
|
|
69
|
+
const s = this.report.summary;
|
|
70
|
+
if (a.gps && !s.gps)
|
|
71
|
+
s.gps = a.gps;
|
|
72
|
+
s.names.push(...a.names);
|
|
73
|
+
s.people.push(...a.people);
|
|
74
|
+
s.software.push(...a.software);
|
|
75
|
+
if (a.motionPhoto)
|
|
76
|
+
this.note('The XMP block declares this is a motion photo with an embedded video.');
|
|
77
|
+
if (a.aiPrompt && !s.aiPrompt)
|
|
78
|
+
s.aiPrompt = a.aiPrompt;
|
|
79
|
+
return a.categories;
|
|
80
|
+
}
|
|
81
|
+
irb(bytes, source, removable = true) {
|
|
82
|
+
const a = analyzeIrb(bytes, source, removable);
|
|
83
|
+
this.findings.push(...a.findings);
|
|
84
|
+
this.report.summary.names.push(...a.names);
|
|
85
|
+
if (a.thumbnail)
|
|
86
|
+
this.report.thumbnails.push(a.thumbnail);
|
|
87
|
+
const cats = new Set(a.categories);
|
|
88
|
+
if (a.xmp)
|
|
89
|
+
for (const c of this.xmp(a.xmp, source, removable))
|
|
90
|
+
cats.add(c);
|
|
91
|
+
return cats;
|
|
92
|
+
}
|
|
93
|
+
finish() {
|
|
94
|
+
const s = this.report.summary;
|
|
95
|
+
s.serialNumbers = unique(s.serialNumbers);
|
|
96
|
+
s.people = unique(s.people);
|
|
97
|
+
s.names = unique(s.names).filter((n) => !s.people.includes(n));
|
|
98
|
+
s.software = unique(s.software);
|
|
99
|
+
this.report.categories = sortCategories(this.findings.map((f) => f.category));
|
|
100
|
+
return this.report;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function unique(a) {
|
|
104
|
+
return [...new Set(a.map((x) => x.trim()).filter(Boolean))];
|
|
105
|
+
}
|
|
106
|
+
export function categoriesOf(findings) {
|
|
107
|
+
return new Set(findings.map((f) => f.category));
|
|
108
|
+
}
|