// scripts/measure-metadata-survival.mjs — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * The measurements behind /guides/what-your-photos-reveal. * * Two ways to get metadata out of a photo, measured side by side: * * 1. Strip it — remove the EXIF segment and leave the compressed image data alone. * This is what /remove-exif does, using src/lib/images/metadata.ts. * 2. Convert it — decode the photo and re-encode it, which drops metadata as a side * effect. This is what every "just convert it to PNG" answer amounts to, * and what the canvas-based converters here do. * * The point of measuring both is that the second is the advice people are usually given, * and it is the one that silently re-encodes the picture. * * Inputs come from scripts/make-metadata-test-photos.py so the whole thing is repeatable. * * Usage: node scripts/measure-metadata-survival.mjs */ import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { chromium } from 'playwright'; import { readMetadata, stripMetadata } from '../src/lib/images/metadata.ts'; const dir = process.argv[2]; if (!dir) { console.error('Usage: node scripts/measure-metadata-survival.mjs '); process.exit(1); } const FILES = ['phone-photo.jpg', 'camera-only.jpg', 'no-metadata.jpg']; const sha = bytes => createHash('sha256').update(bytes).digest('hex'); /** Decode and re-encode through canvas — the conversion path, run in a real browser. */ const ROUND_TRIP = ` /** Mean/worst per-channel drift between the decoded source and a decoded re-encode. */ async function drift(canvas, before, buffer, type) { const reloaded = await createImageBitmap(new Blob([buffer], { type })); const second = document.createElement('canvas'); second.width = reloaded.width; second.height = reloaded.height; second.getContext('2d').drawImage(reloaded, 0, 0); const after = second.getContext('2d').getImageData(0, 0, second.width, second.height).data; let total = 0, worst = 0, changed = 0; for (let i = 0; i < before.length; i += 4) { for (let c = 0; c < 3; c++) { const delta = Math.abs(before[i + c] - after[i + c]); total += delta; if (delta > worst) worst = delta; if (delta > 0) changed++; } } const samples = (before.length / 4) * 3; return { meanPixelDelta: total / samples, worstPixelDelta: worst, changedChannelPercent: (changed / samples) * 100 }; } /** * The same photo re-encoded across JPEG qualities. * * Worth measuring because the advice "convert it to strip the metadata" is really advice * to re-encode, and how much that costs depends entirely on the quality it lands on. */ window.__qualitySweep = async function (base64, qualities) { const binary = atob(base64); const source = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) source[i] = binary.charCodeAt(i); const bitmap = await createImageBitmap(new Blob([source], { type: 'image/jpeg' })); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.getContext('2d').drawImage(bitmap, 0, 0); const before = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; const rows = []; for (const quality of qualities) { const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', quality)); const buffer = new Uint8Array(await blob.arrayBuffer()); rows.push({ quality, size: buffer.length, ...(await drift(canvas, before, buffer, 'image/jpeg')) }); } return rows; }; window.__roundTrip = async function (base64, type, quality) { const binary = atob(base64); const source = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) source[i] = binary.charCodeAt(i); const bitmap = await createImageBitmap(new Blob([source], { type: 'image/jpeg' })); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.getContext('2d').drawImage(bitmap, 0, 0); const blob = await new Promise(resolve => canvas.toBlob(resolve, type, quality)); const buffer = new Uint8Array(await blob.arrayBuffer()); // How far the pixels moved. File size cannot answer this: Chromium's encoder and the // one that wrote the original disagree at the same nominal quality, so a smaller file // does not by itself prove detail was lost. Decoding both and comparing does. const before = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; const reloaded = await createImageBitmap(new Blob([buffer], { type })); const second = document.createElement('canvas'); second.width = reloaded.width; second.height = reloaded.height; second.getContext('2d').drawImage(reloaded, 0, 0); const after = second.getContext('2d').getImageData(0, 0, second.width, second.height).data; let total = 0, worst = 0, changed = 0; for (let i = 0; i < before.length; i += 4) { for (let c = 0; c < 3; c++) { const delta = Math.abs(before[i + c] - after[i + c]); total += delta; if (delta > worst) worst = delta; if (delta > 0) changed++; } } const samples = (before.length / 4) * 3; let out = ''; for (let i = 0; i < buffer.length; i++) out += String.fromCharCode(buffer[i]); return { size: buffer.length, base64: btoa(out), width: bitmap.width, height: bitmap.height, meanPixelDelta: total / samples, worstPixelDelta: worst, changedChannelPercent: (changed / samples) * 100, }; }; `; const browser = await chromium.launch(); const page = await browser.newPage(); await page.setContent('round trip'); await page.addScriptTag({ content: ROUND_TRIP }); const results = {}; for (const name of FILES) { const original = readFileSync(`${dir}/${name}`); const base64 = original.toString('base64'); const originalBytes = new Uint8Array(original); const stripped = stripMetadata(originalBytes); const entry = { originalSize: original.length, originalSha: sha(original), metadataBefore: readMetadata(originalBytes), stripped: { size: stripped.bytes.length, sha: sha(Buffer.from(stripped.bytes)), removed: stripped.removed, metadataAfter: readMetadata(stripped.bytes), }, }; for (const [label, type, quality] of [['jpeg88', 'image/jpeg', 0.88], ['png', 'image/png', undefined]]) { const out = await page.evaluate( ([b, t, q]) => window.__roundTrip(b, t, q), [base64, type, quality], ); const bytes = Buffer.from(out.base64, 'binary'); entry[label] = { size: out.size, sha: sha(bytes), width: out.width, height: out.height, meanPixelDelta: Number(out.meanPixelDelta.toFixed(3)), worstPixelDelta: out.worstPixelDelta, changedChannelPercent: Number(out.changedChannelPercent.toFixed(1)), metadataAfter: readMetadata(new Uint8Array(bytes)), }; } results[name] = entry; } const qualitySweep = await page.evaluate( ([b, q]) => window.__qualitySweep(b, q), [readFileSync(`${dir}/no-metadata.jpg`).toString('base64'), [0.95, 0.88, 0.75, 0.6, 0.4]], ); await browser.close(); console.log(JSON.stringify({ userAgent: 'headless chromium', results, qualitySweep }, null, 2));