@raghuvamsi_rv/compresspics 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/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # compresspics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The engine behind **[compresspics.org](https://compresspics.org)**.
4
+
5
+ Fast, private photo compression that runs in the browser. Images are processed in a Web Worker using `OffscreenCanvas`, so the UI stays responsive and nothing gets uploaded. It has zero dependencies.
6
+
7
+ - Scales the longest side down to 2560px (configurable), with step-down resampling to avoid moiré on fine textures
8
+ - Outputs JPEG or WebP
9
+ - Reads RAW files (**NEF, NRW, DNG, CR2, ARW, PEF**) by extracting the camera's embedded JPEG preview
10
+ - Applies EXIF orientation, including RAW orientation
11
+ - Includes TypeScript types
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install compresspics
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```js
22
+ import { compressImage } from 'compresspics';
23
+
24
+ input.addEventListener('change', async () => {
25
+ const result = await compressImage(input.files[0], {
26
+ targetQuality: 0.8, // 0.75 = smaller, 0.82 = more detail
27
+ maxDimension: 2560,
28
+ mimeType: 'image/jpeg', // or 'image/webp'
29
+ onProgress: (percent, stage) => console.log(stage, percent),
30
+ });
31
+
32
+ console.log(`${(result.reduction * 100).toFixed(1)}% smaller`);
33
+ upload(result.file); // File named "<original>-compressed.jpg"
34
+ });
35
+ ```
36
+
37
+ ### Result
38
+
39
+ | Field | Description |
40
+ | --- | --- |
41
+ | `blob`, `file` | Compressed output |
42
+ | `width`, `height` | Output dimensions |
43
+ | `originalWidth`, `originalHeight` | Source dimensions (for RAW: embedded preview) |
44
+ | `originalSize`, `compressedSize` | Size in bytes |
45
+ | `reduction` | 0–1 fraction saved |
46
+ | `source` | `'image'` or `'raw-preview'` |
47
+ | `preview` | Extracted RAW preview blob, or `null` |
48
+
49
+ Other exports: `DEFAULTS`, `RAW_EXTENSIONS`, `isRawFile(file)`, `terminate()`.
50
+
51
+ ## Notes
52
+
53
+ - The package is browser-only. It needs `OffscreenCanvas` in workers, which is available in Chrome 69+, Firefox 105+ and Safari 16.4+. You can still import it during SSR, because the worker is only created on first use.
54
+ - The worker is inlined as a Blob URL, so it works from any bundler or CDN. If your site has a Content Security Policy, it must allow `worker-src blob:`.
55
+ - RAW output is the camera's own rendering of the shot. Edits made in Lightroom or similar tools aren't applied. Some DNGs only include a small preview.
56
+
57
+ ## Deploy to a Proxmox LXC
58
+
59
+ The `Deploy site` GitHub Actions workflow builds the site and synchronizes `site/` to the LXC's web root whenever a commit is pushed to `main`. It can also be started manually from the repository's Actions tab. The separate `Test Server Connection` workflow can be run manually to verify SSH access before deploying.
60
+
61
+ Create a Debian or Ubuntu LXC in Proxmox with a stable IP address. Use the distribution's default Nginx site in the LXC to serve `/var/www/html`; point your existing Nginx Proxy Manager proxy host at the LXC's address on port `80` and manage the public hostname and TLS certificate there. No custom Nginx site configuration is needed. Install rsync in the LXC. The current workflow uses a GitHub-hosted runner, so its SSH endpoint must be reachable from GitHub Actions. For an LXC kept private behind your LAN firewall, run a self-hosted GitHub Actions runner inside that network and change the workflow's `runs-on` value to that runner's label instead of exposing SSH publicly.
62
+
63
+ In the container, create a dedicated account and a web root it can update:
64
+
65
+ ```sh
66
+ apt update
67
+ apt install -y nginx rsync
68
+ useradd --create-home --shell /bin/bash deploy
69
+ chown deploy:www-data /var/www/html
70
+ chmod 0755 /var/www/html
71
+ install -d -o deploy -g deploy -m 0700 /home/deploy/.ssh
72
+ ```
73
+
74
+ Generate a dedicated Ed25519 key pair on a trusted machine, without a passphrase for unattended Actions use:
75
+
76
+ ```sh
77
+ ssh-keygen -t ed25519 -C github-actions-compresspics -f compresspics-deploy
78
+ ```
79
+
80
+ Add the contents of `compresspics-deploy.pub` to `/home/deploy/.ssh/authorized_keys` in the container, then secure it:
81
+
82
+ ```sh
83
+ cat compresspics-deploy.pub >> /home/deploy/.ssh/authorized_keys
84
+ chown deploy:deploy /home/deploy/.ssh/authorized_keys
85
+ chmod 0600 /home/deploy/.ssh/authorized_keys
86
+ ```
87
+
88
+ In the GitHub repository, add these Actions secrets:
89
+
90
+ - `SSH_PRIVATE_KEY`: the full private key from `compresspics-deploy`
91
+ - `SERVER_HOST`: the LXC's reachable public DNS name or IP address
92
+ - `SERVER_USER`: `deploy`
93
+ - `SERVER_PORT`: `2343`
94
+ - `SERVER_KNOWN_HOSTS`: the LXC's SSH host-key entry, gathered and verified from a trusted network (for example, `ssh-keyscan -H -p 2343 <public-host>`)
95
+
96
+ Add this Actions variable:
97
+
98
+ - `DEPLOY_PATH`: `/var/www/html`
99
+
100
+ In Nginx Proxy Manager, configure the domain to forward to the LXC's address on port `80` and manage the public TLS certificate there. Once the workflow secrets and variables are set, pushes to `main` deploy automatically. The workflow does not configure the Proxmox LXC, DNS, firewall, or Nginx Proxy Manager.
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,35 @@
1
+ export type OutputMimeType = 'image/jpeg' | 'image/webp';
2
+
3
+ export interface CompressOptions {
4
+ /** Encoder quality 0–1. Default 0.8. */
5
+ targetQuality?: number;
6
+ /** Longest side in pixels. Default 2560. */
7
+ maxDimension?: number;
8
+ /** Default 'image/jpeg'. */
9
+ mimeType?: OutputMimeType;
10
+ onProgress?: (percent: number, stage: string) => void;
11
+ }
12
+
13
+ export interface CompressResult {
14
+ blob: Blob;
15
+ /** Compressed output as a File named `<original>-compressed.<ext>`. */
16
+ file: File;
17
+ width: number;
18
+ height: number;
19
+ originalWidth: number;
20
+ originalHeight: number;
21
+ originalSize: number;
22
+ compressedSize: number;
23
+ /** 0–1, e.g. 0.82 means 82% smaller. */
24
+ reduction: number;
25
+ source: 'image' | 'raw-preview';
26
+ /** Embedded JPEG extracted from a RAW file, if any. */
27
+ preview: Blob | null;
28
+ }
29
+
30
+ export const DEFAULTS: Readonly<Required<Omit<CompressOptions, 'onProgress'>>>;
31
+ export const RAW_EXTENSIONS: string[];
32
+ export function isRawFile(file: File | Blob): boolean;
33
+ export function compressImage(file: File | Blob, options?: CompressOptions): Promise<CompressResult>;
34
+ /** Stops the background worker and rejects pending jobs. */
35
+ export function terminate(): void;
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
1
+ // Public Promise-based API. Talks to worker.js; one image at a time for now.
2
+
3
+ import { getWorkerUrl } from './worker-url.js';
4
+
5
+ export const DEFAULTS = Object.freeze({
6
+ targetQuality: 0.8, // sweet spot: 0.75 if files too big, 0.82 if details look fuzzy
7
+ maxDimension: 2560,
8
+ mimeType: 'image/jpeg',
9
+ });
10
+
11
+ export const RAW_EXTENSIONS = ['.nef', '.nrw', '.dng', '.cr2', '.arw', '.pef'];
12
+ const RAW_PATTERN = new RegExp(`(${RAW_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
13
+
14
+ export const isRawFile = (file) => RAW_PATTERN.test(file?.name ?? '');
15
+
16
+ let worker = null;
17
+ let nextId = 0;
18
+ const pending = new Map();
19
+
20
+ function getWorker() {
21
+ if (worker) return worker;
22
+
23
+ if (typeof OffscreenCanvas === 'undefined' || typeof Worker === 'undefined') {
24
+ throw new Error('This browser does not support OffscreenCanvas in Web Workers.');
25
+ }
26
+
27
+ worker = new Worker(getWorkerUrl());
28
+
29
+ worker.onmessage = ({ data }) => {
30
+ const job = pending.get(data.id);
31
+ if (!job) return;
32
+
33
+ if (data.type === 'progress') {
34
+ job.onProgress?.(data.percent, data.stage);
35
+ return;
36
+ }
37
+
38
+ pending.delete(data.id);
39
+
40
+ if (data.type === 'error') {
41
+ job.reject(new Error(data.message));
42
+ return;
43
+ }
44
+
45
+ job.onProgress?.(100, 'done');
46
+ const originalSize = job.file.size;
47
+ const compressedSize = data.blob.size;
48
+ job.resolve({
49
+ blob: data.blob,
50
+ file: new File([data.blob], renameExtension(job.file.name, job.mimeType), {
51
+ type: job.mimeType,
52
+ }),
53
+ width: data.width,
54
+ height: data.height,
55
+ originalWidth: data.originalWidth,
56
+ originalHeight: data.originalHeight,
57
+ originalSize,
58
+ compressedSize,
59
+ reduction: 1 - compressedSize / originalSize,
60
+ source: data.preview ? 'raw-preview' : 'image',
61
+ preview: data.preview ?? null,
62
+ });
63
+ };
64
+
65
+ worker.onerror = (event) => {
66
+ for (const job of pending.values()) job.reject(new Error(event.message || 'Worker crashed'));
67
+ pending.clear();
68
+ worker.terminate();
69
+ worker = null;
70
+ };
71
+
72
+ return worker;
73
+ }
74
+
75
+ function renameExtension(name, mimeType) {
76
+ const ext = mimeType === 'image/webp' ? 'webp' : 'jpg';
77
+ return name.replace(/\.[^.]+$/, '') + `-compressed.${ext}`;
78
+ }
79
+
80
+ /**
81
+ * @param {File|Blob} file
82
+ * @param {{ targetQuality?: number, maxDimension?: number, mimeType?: string,
83
+ * onProgress?: (percent: number, stage: string) => void }} [options]
84
+ */
85
+ export function compressImage(file, options = {}) {
86
+ const isRaw = isRawFile(file);
87
+ if (!(file instanceof Blob) || (!isRaw && !file.type.startsWith('image/'))) {
88
+ return Promise.reject(new TypeError('Expected an image or RAW (NEF/DNG/CR2/ARW/PEF) file.'));
89
+ }
90
+
91
+ const { targetQuality, maxDimension, mimeType } = { ...DEFAULTS, ...options };
92
+
93
+ return new Promise((resolve, reject) => {
94
+ const id = ++nextId;
95
+ pending.set(id, { resolve, reject, file, mimeType, onProgress: options.onProgress });
96
+ try {
97
+ getWorker().postMessage({ id, file, maxDimension, quality: targetQuality, mimeType, isRaw });
98
+ } catch (err) {
99
+ pending.delete(id);
100
+ reject(err);
101
+ }
102
+ });
103
+ }
104
+
105
+ export function terminate() {
106
+ worker?.terminate();
107
+ worker = null;
108
+ for (const job of pending.values()) job.reject(new Error('Compressor terminated'));
109
+ pending.clear();
110
+ }
@@ -0,0 +1,3 @@
1
+ let url;
2
+ export const getWorkerUrl = () =>
3
+ (url ??= URL.createObjectURL(new Blob(["// Background engine: decode -> resize -> encode, entirely off the main thread.\n\nfunction fitWithin(width, height, maxDimension) {\n const longest = Math.max(width, height);\n if (longest <= maxDimension) return { width, height };\n const scale = maxDimension / longest;\n return {\n width: Math.round(width * scale),\n height: Math.round(height * scale),\n };\n}\n\nfunction createContext(width, height) {\n const canvas = new OffscreenCanvas(width, height);\n const ctx = canvas.getContext('2d', { alpha: true });\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n return { canvas, ctx };\n}\n\n// Halve repeatedly before the final resize: a single big jump aliases fine\n// patterns (lace, fabric, seeds) into moiré.\nfunction resize(source, targetWidth, targetHeight) {\n let current = source;\n let w = source.width;\n let h = source.height;\n\n while (w / 2 >= targetWidth && h / 2 >= targetHeight) {\n w = Math.round(w / 2);\n h = Math.round(h / 2);\n const { canvas, ctx } = createContext(w, h);\n ctx.drawImage(current, 0, 0, w, h);\n current = canvas;\n }\n\n const { canvas, ctx } = createContext(targetWidth, targetHeight);\n ctx.drawImage(current, 0, 0, targetWidth, targetHeight);\n return canvas;\n}\n\n// --- RAW support: NEF/DNG/CR2/ARW/PEF are TIFF containers with embedded JPEG previews ---\n\nfunction parseJpegHeader(bytes, start, end) {\n if (bytes[start] !== 0xff || bytes[start + 1] !== 0xd8) return null;\n let hasExif = false;\n let pos = start + 2;\n while (pos + 9 <= end) {\n if (bytes[pos] !== 0xff) return null;\n const marker = bytes[pos + 1];\n if (marker === 0xff) { pos++; continue; }\n // SOF0-2 are decodable by browsers; SOF3 etc. are lossless RAW data.\n if (marker >= 0xc0 && marker <= 0xc2) {\n return {\n height: (bytes[pos + 5] << 8) | bytes[pos + 6],\n width: (bytes[pos + 7] << 8) | bytes[pos + 8],\n hasExif,\n };\n }\n if ((marker >= 0xc3 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) || marker === 0xda) {\n return null;\n }\n if (marker === 0xe1 && String.fromCharCode(...bytes.subarray(pos + 4, pos + 8)) === 'Exif') hasExif = true;\n pos += 2 + ((bytes[pos + 2] << 8) | bytes[pos + 3]);\n }\n return null;\n}\n\nfunction extractRawPreview(buffer) {\n const view = new DataView(buffer);\n const bytes = new Uint8Array(buffer);\n const size = buffer.byteLength;\n const order = size >= 8 ? view.getUint16(0) : 0;\n if (order !== 0x4949 && order !== 0x4d4d) throw new Error('Unsupported RAW format (not TIFF-based).');\n const le = order === 0x4949;\n const u16 = (o) => view.getUint16(o, le);\n const u32 = (o) => view.getUint32(o, le);\n if (u16(2) !== 42) throw new Error('Unsupported RAW format (not TIFF-based).');\n\n const readValues = (entry, type, count) => {\n const width = type === 3 ? 2 : type === 4 || type === 13 ? 4 : 0;\n if (!width || count > 4096) return [];\n const base = width * count <= 4 ? entry + 8 : u32(entry + 8);\n if (base + width * count > size) return [];\n return Array.from({ length: count }, (_, i) => (width === 2 ? u16(base + i * 2) : u32(base + i * 4)));\n };\n\n const candidates = [];\n const visited = new Set();\n const queue = [u32(4)];\n let orientation = 1;\n let isFirstIfd = true;\n\n while (queue.length && visited.size < 64) {\n const ifd = queue.shift();\n if (!ifd || visited.has(ifd) || ifd + 2 > size) continue;\n visited.add(ifd);\n\n const count = u16(ifd);\n const tags = {};\n for (let i = 0; i < count; i++) {\n const entry = ifd + 2 + i * 12;\n if (entry + 12 > size) break;\n tags[u16(entry)] = readValues(entry, u16(entry + 2), u32(entry + 4));\n }\n\n if (isFirstIfd && tags[0x112]?.[0]) orientation = tags[0x112][0];\n isFirstIfd = false;\n\n // JPEGInterchangeFormat / Length (NEF JpgFromRaw, CR2, ARW)\n if (tags[0x201]?.length && tags[0x202]?.length) {\n candidates.push({ offset: tags[0x201][0], length: tags[0x202][0] });\n }\n // Single-strip JPEG-compressed image (DNG previews)\n const compression = tags[0x103]?.[0];\n if ((compression === 6 || compression === 7) && tags[0x111]?.length === 1 && tags[0x117]?.length === 1) {\n candidates.push({ offset: tags[0x111][0], length: tags[0x117][0] });\n }\n\n if (tags[0x14a]) queue.push(...tags[0x14a]);\n const nextPtr = ifd + 2 + count * 12;\n if (nextPtr + 4 <= size) queue.push(u32(nextPtr));\n }\n\n let best = null;\n for (const c of candidates) {\n if (c.offset + c.length > size) continue;\n const header = parseJpegHeader(bytes, c.offset, c.offset + c.length);\n if (header && (!best || header.width * header.height > best.width * best.height)) {\n best = { ...c, ...header };\n }\n }\n if (!best) throw new Error('No decodable JPEG preview found in this RAW file.');\n return { ...best, orientation: best.hasExif ? 1 : orientation };\n}\n\nfunction applyOrientation(canvas, orientation) {\n if (!orientation || orientation === 1) return canvas;\n const w = canvas.width;\n const h = canvas.height;\n const swap = orientation >= 5;\n const { canvas: out, ctx } = createContext(swap ? h : w, swap ? w : h);\n const transforms = {\n 2: [-1, 0, 0, 1, w, 0],\n 3: [-1, 0, 0, -1, w, h],\n 4: [1, 0, 0, -1, 0, h],\n 5: [0, 1, 1, 0, 0, 0],\n 6: [0, 1, -1, 0, h, 0],\n 7: [0, -1, -1, 0, h, w],\n 8: [0, -1, 1, 0, 0, w],\n };\n if (!transforms[orientation]) return canvas;\n ctx.setTransform(...transforms[orientation]);\n ctx.drawImage(canvas, 0, 0);\n return out;\n}\n\nfunction flattenOnWhite(canvas) {\n const { canvas: out, ctx } = createContext(canvas.width, canvas.height);\n ctx.fillStyle = '#fff';\n ctx.fillRect(0, 0, out.width, out.height);\n ctx.drawImage(canvas, 0, 0);\n return out;\n}\n\nself.onmessage = async (event) => {\n const { id, file, maxDimension, quality, mimeType, isRaw } = event.data;\n const progress = (stage, percent) =>\n self.postMessage({ id, type: 'progress', stage, percent });\n\n let bitmap;\n try {\n let source = file;\n let preview = null;\n let orientation = 1;\n if (isRaw) {\n progress('extracting RAW preview', 5);\n const raw = extractRawPreview(await file.arrayBuffer());\n preview = file.slice(raw.offset, raw.offset + raw.length, 'image/jpeg');\n source = preview;\n orientation = raw.orientation;\n }\n\n progress('decoding', 10);\n bitmap = await createImageBitmap(source, { imageOrientation: 'from-image' });\n\n progress('resizing', 40);\n const fitted = fitWithin(bitmap.width, bitmap.height, maxDimension);\n let canvas = applyOrientation(resize(bitmap, fitted.width, fitted.height), orientation);\n const { width, height } = canvas;\n const swapped = orientation >= 5 && orientation <= 8;\n\n // JPEG has no alpha; transparent pixels would otherwise turn black.\n if (mimeType === 'image/jpeg') canvas = flattenOnWhite(canvas);\n\n progress('encoding', 70);\n const blob = await canvas.convertToBlob({ type: mimeType, quality });\n\n self.postMessage({\n id,\n type: 'done',\n blob,\n width,\n height,\n originalWidth: swapped ? bitmap.height : bitmap.width,\n originalHeight: swapped ? bitmap.width : bitmap.height,\n preview,\n });\n } catch (err) {\n self.postMessage({ id, type: 'error', message: err?.message || String(err) });\n } finally {\n bitmap?.close();\n }\n};\n"], { type: 'text/javascript' })));
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@raghuvamsi_rv/compresspics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Fast, private, in-browser photo compression with RAW (NEF, DNG, CR2, ARW, PEF) support. Runs in a Web Worker with OffscreenCanvas. Zero dependencies.",
5
+ "homepage": "https://compresspics.org",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "node scripts/build.mjs",
22
+ "start": "npm run build && python3 -m http.server 5173 --directory site",
23
+ "deploy": "npm run build && rsync -avz --delete --exclude .DS_Store site/ \"${DEPLOY_TARGET:?Set DEPLOY_TARGET=user@host:/var/www/compresspics.org}\"",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "image",
28
+ "compression",
29
+ "compress",
30
+ "photo",
31
+ "jpeg",
32
+ "webp",
33
+ "raw",
34
+ "nef",
35
+ "dng",
36
+ "web-worker",
37
+ "offscreencanvas",
38
+ "browser"
39
+ ],
40
+ "license": "MIT",
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "devDependencies": {
45
+ "fflate": "^0.8.3"
46
+ }
47
+ }