Học từ WhatsApp Web #3: Xoá EXIF không nén lại, thumbnail vừa 20 KB
WhatsApp Web bỏ EXIF và GPS khỏi ảnh JPEG bằng cách chép lại segment, giữ nguyên điểm ảnh; nén thumbnail cho vừa 20 KB và kiểm frame video đen trước khi lấy.
Ảnh chụp từ điện thoại mang theo nhiều thứ hơn người gửi nghĩ: hãng và đời máy, thời điểm chụp, và nếu bật định vị thì cả toạ độ GPS của căn nhà. Một app nhắn tin phải xoá những thứ đó trước khi gửi. Cách dễ nhất là vẽ ảnh lên <canvas> rồi xuất lại JPEG: metadata mất sạch, nhưng ảnh bị nén lại thêm một lần, mờ đi và đôi khi còn to hơn file gốc.
WhatsApp Web có hai đường. Ảnh không cần xoay, không cần thu nhỏ thì đi đường "phẫu thuật": đọc từng segment của file JPEG, bỏ segment metadata, chép nguyên phần còn lại. Ảnh cần xử lý thì mới vẽ lên canvas. Cùng nhóm tiện ích đó còn có hàm nén thumbnail cho vừa một ngân sách byte cố định và hàm lấy thumbnail video có kiểm frame đen. Cả ba đều có chỗ đáng học, và vài lỗi đáng sửa.
Tip
Tóm tắt
- JPEG là chuỗi segment có độ dài ghi sẵn. Bỏ APP1 (Exif, XMP), APP13 (IPTC), COM rồi chép phần còn lại: dữ liệu nén giữ nguyên từng byte, điểm ảnh không đổi, file nhỏ đi.
cleanJPEGgốc ném lỗi khi gặp APP2 (ICC profile, có trong rất nhiều ảnh điện thoại) hay APP14 (Adobe), và rơi về re-encode. Bản viết lại giữ hai segment đó.- Thumbnail ảnh phải ≤ 20 000 byte: bản gốc thử lần lượt chất lượng 0.9, 0.8, … (2 tới 11 lần encode, càng chặt càng nhiều). Tìm nhị phân giới hạn ở 7 lần và cho chất lượng sát ngân sách hơn.
- Kiểm "frame đen" của thumbnail video chỉ lấy 5 điểm, cả 5 đều nằm trong 1/4 trên cùng của ảnh: video có viền đen bị coi là đen, chờ thêm tới 5 giây. Lấy mẫu dạng lưới phủ cả khung sửa được.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Phần 1: bấm Xoá metadata. Ô "điểm ảnh khác" của cách chép segment luôn là 0, còn re-encode qua canvas thì hàng chục nghìn điểm ảnh đổi màu. Bật Ảnh có APP2 ICC profile để thấy cleanJPEG gốc sẽ ném lỗi. Phần 2 so số lần encode và chất lượng đạt được giữa vòng lặp gốc và tìm nhị phân; thử cả ba mức ngân sách. Phần 3 vẽ điểm lấy mẫu: đỏ là 5 điểm của bản gốc, trắng là lưới 6×6.
#WhatsApp Web làm thế nào?
Ba module: cjjjfdegjf chứa cleanJPEG, dgjacfgggf chứa các tiện ích ảnh (rotateAndResize, getResizedDataUrl, generateMicroThumb, generateVideoThumbAndDuration), và bccggdcfj là nơi gọi chúng khi chuẩn bị gửi ảnh. Đây là bundle bản 2020, tên module là hash webpack.
#cleanJPEG: đọc segment, chép phần cần giữ
Hàm đọc file bằng hai con trỏ chạy song song, gặp marker nào thì quyết định chép hay bỏ:
| Marker | Xử lý trong cleanJPEG |
|---|---|
FFD8 SOI |
bắt buộc ở đầu file |
FFE0 APP0 (JFIF) |
đọc version, đơn vị và mật độ điểm ảnh rồi bỏ, cuối cùng dựng lại một JFIF 18 byte |
FFE1 APP1 |
segment APP1 đầu tiên phải là Exif\0\0, bỏ |
FFED APP13, FFFE COM |
bỏ |
| SOF0–SOF15, DHT, DQT, DRI | chép nguyên |
FFDA SOS |
chép header và dữ liệu entropy tới marker kế tiếp |
FFD9 EOI |
chép, dừng |
| APP2–APP12, APP14, APP15 | throw "Received unexpected APP marker" |
Ngoài ra, nếu gặp SOF, DHT, SOS… mà trước đó chưa thấy JFIF hay Exif thì ném "Missing magic bytes marker.". Đoạn quét dữ liệu entropy là chỗ tinh tế nhất:
case SOS:
a.advance(a.readUint16() - 2); // bỏ qua header SOS
for (let cur, prev = a.readUint8(); a.size(); ) {
cur = a.readUint8();
if (prev === 0xff && ![0, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7].includes(cur)) { marker = cur; break; }
prev = cur;
}
out.writeByteArray(copy.readByteArray(copy.size() - a.size() - 2));
continue;
js
Trong dữ liệu nén, byte 0xFF thật được "nhồi" thành FF 00, còn FF D0…D7 là marker khởi động lại (RST). Mọi FF xx khác là marker của segment kế tiếp. Nhờ vậy hàm xử lý được cả JPEG progressive có nhiều SOS xen DHT.
#Khi nào đi đường cleanJPEG?
rotateAndResize trong dgjacfgggf đọc Exif để biết hướng ảnh, rồi:
if (!rotation) { // size = boundHeightWidth(h, w, maxEdge)
let needBlob = flags & BLOB;
if (buffer && needBlob && w === size.width && h === size.height)
try { images.blob = cleanJPEG(buffer); needBlob = false; } catch (e) { /* log "Could not parse JPEG" */ }
if (needBlob || flags & ~BLOB) { /* vẽ lên canvas nền rgb(247,247,247), xuất JPEG */ }
}
js
Chỉ khi ảnh không cần xoay và không phải thu nhỏ thì mới thử cleanJPEG. Lỗi thì ghi log và đi tiếp xuống nhánh canvas. Trước khi vẽ, canvas được tô màu rgb(247,247,247): ảnh PNG trong suốt chuyển sang JPEG sẽ có nền xám rất nhạt thay vì nền đen.
#Thumbnail theo ngân sách byte
processRawImage trong bccggdcfj thu nhỏ ảnh về cạnh tối đa của thumbnail rồi gọi:
const url = getResizedDataUrl(canvas, 'image/jpeg', 2e4); // ngưỡng 20 000 byte nằm ở đây
const preview = url ? parseDataURL(url).data : null; // log: "preview will be null"
js
Còn getResizedDataUrl trong dgjacfgggf thử từ chất lượng mặc định xuống dần:
if (dataURLtoBlob(canvas.toDataURL(type)).size <= maxBytes) return canvas.toDataURL(type);
for (let q = 0.9; q >= 0.1; q -= 0.1) {
const url = canvas.toDataURL(type, q);
if (dataURLtoBlob(url).size <= maxBytes) return url;
}
return null;
js
Do cộng trừ số thực, q đi qua 0.9, 0.8, 0.7000000000000001… và lần cuối là 0.10000000000000014, vẫn ≥ 0.1, nên đủ 9 mức. Trường hợp xấu nhất là 11 lần encode, mỗi lần còn giải base64 để đo kích thước. Ảnh vừa ngay từ đầu thì bị encode hai lần giống hệt nhau.
Thumbnail của tài liệu (trang đầu PDF) thì dùng generateMicroThumb(canvas, 1300, { mimetype: 'image/jpeg', maxAttempts: 10 }), gọi từ bbegjdfcja: mỗi lần chưa vừa 1 300 byte thì thu kích thước còn một nửa.
#Thumbnail video và frame đen
Với một số bản Chrome có lỗi nạp video (isBuggyVideoLoadChrome), frame đầu vẽ ra canvas có thể toàn đen. Bản gốc thử lại tối đa 100 lần, mỗi lần cách 50 ms:
const isBlack = img => {
for (let t = img.width * img.height / 4, a = 0; a <= 1; a += 0.25) {
const i = 4 * Math.round(t * a);
if (img.data[i] || img.data[i + 1] || img.data[i + 2]) return false;
if (img.data[i + 3] !== 0 && img.data[i + 3] !== 255) return false;
}
return true;
};
js
t là một phần tư số điểm ảnh, nên 5 chỉ số 0, t/4, t/2, 3t/4, t đều nằm trong 1/4 đầu mảng, tức 1/4 chiều cao phía trên. Hơn nữa khi chiều cao chia hết cho 16 (720, 1280…), cả 5 điểm rơi đúng cột x = 0. Video có viền đen phía trên (letterbox) hoặc hai bên (pillarbox), rất hay gặp ở video quay màn hình hay video dọc ghép nền đen, sẽ luôn bị coi là "đen". Kết quả: chờ đủ 100 lần × 50 ms = 5 giây rồi mới lấy frame, cho dù frame đó hoàn toàn bình thường.
#Bên trong trình duyệt: segment JPEG và encoder canvas
Mỗi segment JPEG bắt đầu bằng FF xx, theo sau là 2 byte độ dài (big-endian, tính cả 2 byte đó). Trình giải mã chỉ cần các bảng lượng tử hoá (DQT), bảng Huffman (DHT), khung (SOF) và dữ liệu quét (SOS). Các segment APPn là "phụ lục" cho phần mềm khác đọc. Bỏ chúng đi thì dữ liệu nén y nguyên, decoder ra đúng từng điểm ảnh như cũ.
Ngược lại, canvas.toBlob('image/jpeg', q) giải nén ảnh ra điểm ảnh rồi nén lại bằng encoder của trình duyệt, với bảng lượng tử hoá của trình duyệt chứ không phải của máy ảnh. Mỗi lần như vậy mất thêm chi tiết. Chất lượng mặc định của Chrome là 0.92, cao hơn nhiều máy ảnh, nên file đầu ra hay to hơn file gốc dù ảnh xấu đi.
Không phải APPn nào cũng bỏ được an toàn:
- APP2
ICC_PROFILE: hồ sơ màu. iPhone chụp ở không gian màu Display P3; bỏ ICC thì trình duyệt hiểu thành sRGB, ảnh nhạt màu đi. - APP14
Adobe: cho biết dữ liệu là YCbCr hay RGB/CMYK. Bỏ đi, ảnh CMYK có thể ra màu âm bản. - APP1 Exif mang tag
Orientation. Bỏ Exif của ảnh cần xoay thì ảnh hiện ngang. Đây là lý do bản gốc chỉ đi đườngcleanJPEGkhi không cần xoay.
flowchart TD
A["Ảnh người dùng chọn"] --> B{"Exif Orientation cần xoay?"}
B -->|"có: right-top, left-bottom, bottom-right"| C["Vẽ lên canvas có xoay, xuất JPEG"]
B -->|"không"| D{"Lớn hơn cạnh tối đa?"}
D -->|"có"| E["Vẽ thu nhỏ lên canvas nền 247,247,247"]
D -->|"không"| F["cleanJPEG: chép segment"]
F -->|"throw, vd gặp APP2"| E
F -->|"ok"| G["Blob gửi đi, điểm ảnh y nguyên"]
C --> H["Thumbnail: getResizedDataUrl ≤ 20 000 byte"]
E --> H
G --> H
#Viết lại
src/lib/waJpegTools.ts tách phần đọc segment thành hàm riêng để vừa xoá metadata vừa liệt kê được cho demo. Đọc segment, chịu được byte đệm 0xFF liên tiếp trước marker (chuẩn cho phép, bản gốc ném lỗi):
src/lib/waJpegTools.ts
/** Đi qua từng segment. Chịu được byte đệm 0xFF trước marker và nhiều SOS (progressive). */
export function listJpegSegments(b: Uint8Array): JpegSegment[] {
if (b[0] !== 0xff || b[1] !== 0xd8) throw new Error('thiếu SOI ở đầu file');
const out: JpegSegment[] = [{ marker: 0xd8, name: 'SOI', offset: 0, size: 2 }];
let i = 2;
while (i < b.length) {
if (b[i] !== 0xff) throw new Error(`byte 0x${b[i].toString(16)} ở ${i} không phải marker`);
const offset = i;
while (b[i] === 0xff) i++; // byte đệm hợp lệ theo chuẩn
const marker = b[i++];
if (marker === 0xd9) {
out.push({ marker, name: 'EOI', offset, size: i - offset });
return out;
}
const len = (b[i] << 8) | b[i + 1];
let end = i + len;
if (marker === 0xda) {
while (end + 1 < b.length && !(b[end] === 0xff && b[end + 1] !== 0 && (b[end + 1] < 0xd0 || b[end + 1] > 0xd7)))
end++;
}
const tag = marker >= 0xe0 && marker <= 0xef ? readTag(b, i + 2, 12) : undefined;
out.push({ marker, name: markerName(marker), offset, size: end - offset, tag });
i = end;
}
throw new Error('không thấy EOI');
}
ts
Chọn bỏ theo nội dung chứ không chỉ theo số marker: isStrippable bỏ COM và mọi APPn, trừ APP0 có tag JFIF, APP2 có tag ICC_PROFILE và APP14 có tag Adobe. APP2 cũng có thể là MPF (danh mục ảnh phụ), loại này thì bỏ, nên phải đọc chuỗi nhận dạng ở đầu payload chứ không chỉ nhìn số marker.
Nén theo ngân sách bằng tìm nhị phân trên chất lượng, đo kích thước thẳng từ Blob:
src/lib/waJpegTools.ts
export async function encodeWithinBudget(
canvas: HTMLCanvasElement,
maxBytes: number,
type = 'image/jpeg',
{ min = 0.1, max = 0.92, steps = 6 } = {},
): Promise<BudgetResult | null> {
let encodes = 0;
const at = async (q: number) => {
encodes++;
return canvasToBlob(canvas, type, q);
};
const top = await at(max);
if (top && top.size <= maxBytes) return { blob: top, quality: max, encodes };
let best: { blob: Blob; quality: number } | null = null;
let lo = min;
let hi = max;
for (let i = 0; i < steps; i++) {
const q = i === 0 ? lo : (lo + hi) / 2;
const blob = await at(q);
if (blob && blob.size <= maxBytes) {
best = { blob, quality: q };
lo = q;
if (blob.size >= maxBytes * 0.9) break; // đã sát ngân sách: tăng chất lượng thêm cũng không đáng
} else if (i === 0) {
return null; // chất lượng thấp nhất còn quá lớn: phải thu nhỏ kích thước
} else hi = q;
}
return best && { ...best, encodes };
}
ts
Xem toàn bộ src/lib/waJpegTools.ts
src/lib/waJpegTools.ts
// Xoá metadata JPEG mà không re-encode, nén thumbnail vừa ngân sách byte, phát hiện frame đen.
// Viết lại từ cjjjfdegjf (cleanJPEG) và dgjacfgggf (tiện ích ảnh) của WhatsApp Web bản 2020.
export interface JpegSegment {
marker: number;
name: string;
offset: number;
/** tổng số byte của segment, gồm 2 byte marker (với SOS: gồm cả dữ liệu entropy) */
size: number;
/** chuỗi nhận dạng ở đầu payload APPn: "Exif", "ICC_PROFILE", "Adobe"… */
tag?: string;
}
const NAMES: Record<number, string> = { 0xc4: 'DHT', 0xdb: 'DQT', 0xdd: 'DRI', 0xda: 'SOS', 0xd9: 'EOI', 0xfe: 'COM' };
export function markerName(m: number): string {
if (NAMES[m]) return NAMES[m];
if (m >= 0xe0 && m <= 0xef) return `APP${m - 0xe0}`;
if (m >= 0xc0 && m <= 0xcf) return `SOF${m - 0xc0}`;
return `0x${m.toString(16)}`;
}
function readTag(b: Uint8Array, at: number, max: number): string {
let s = '';
for (let i = at; i < at + max && b[i] >= 0x20 && b[i] < 0x7f; i++) s += String.fromCharCode(b[i]);
return s;
}
/** Đi qua từng segment. Chịu được byte đệm 0xFF trước marker và nhiều SOS (progressive). */
export function listJpegSegments(b: Uint8Array): JpegSegment[] {
if (b[0] !== 0xff || b[1] !== 0xd8) throw new Error('thiếu SOI ở đầu file');
const out: JpegSegment[] = [{ marker: 0xd8, name: 'SOI', offset: 0, size: 2 }];
let i = 2;
while (i < b.length) {
if (b[i] !== 0xff) throw new Error(`byte 0x${b[i].toString(16)} ở ${i} không phải marker`);
const offset = i;
while (b[i] === 0xff) i++; // byte đệm hợp lệ theo chuẩn
const marker = b[i++];
if (marker === 0xd9) {
out.push({ marker, name: 'EOI', offset, size: i - offset });
return out;
}
const len = (b[i] << 8) | b[i + 1];
let end = i + len;
if (marker === 0xda) {
while (end + 1 < b.length && !(b[end] === 0xff && b[end + 1] !== 0 && (b[end + 1] < 0xd0 || b[end + 1] > 0xd7)))
end++;
}
const tag = marker >= 0xe0 && marker <= 0xef ? readTag(b, i + 2, 12) : undefined;
out.push({ marker, name: markerName(marker), offset, size: end - offset, tag });
i = end;
}
throw new Error('không thấy EOI');
}
/** Segment nào là metadata riêng tư/không cần để hiển thị. */
export function isStrippable(s: JpegSegment): boolean {
if (s.marker === 0xfe) return true; // COM
if (s.marker < 0xe0 || s.marker > 0xef) return false;
if (s.marker === 0xe0) return s.tag !== 'JFIF'; // giữ JFIF (mật độ điểm ảnh), bỏ JFXX chứa thumbnail
if (s.marker === 0xe2 && s.tag === 'ICC_PROFILE') return false; // màu Display P3 của điện thoại
if (s.marker === 0xee && s.tag?.startsWith('Adobe')) return false; // cho biết YCbCr/CMYK
return true; // Exif, XMP, IPTC (APP13), MPF (APP2), APP3..15 lạ
}
/**
* Bỏ metadata bằng cách chép lại các segment cần giữ: dữ liệu ảnh (DQT, DHT, SOF, SOS…) giữ nguyên
* từng byte nên điểm ảnh không đổi. Lưu ý: Exif mang cả Orientation; chỉ dùng khi ảnh không cần xoay.
*/
export function stripJpegMetadata(b: Uint8Array): { bytes: Uint8Array<ArrayBuffer>; removed: JpegSegment[] } {
const segs = listJpegSegments(b);
const keep: Uint8Array[] = [];
const removed: JpegSegment[] = [];
for (const s of segs) {
if (isStrippable(s)) removed.push(s);
else keep.push(b.subarray(s.offset, s.offset + s.size));
}
const out = new Uint8Array(keep.reduce((n, p) => n + p.byteLength, 0));
let at = 0;
for (const p of keep) {
out.set(p, at);
at += p.byteLength;
}
return { bytes: out, removed };
}
export interface ExifSummary {
orientation: number;
make?: string;
model?: string;
hasGps: boolean;
}
/** Đọc vài tag IFD0 trong APP1 Exif: đủ để biết có cần xoay và có lộ GPS không. */
export function readExifSummary(b: Uint8Array): ExifSummary | null {
const seg = listJpegSegments(b).find((s) => s.marker === 0xe1 && s.tag === 'Exif');
if (!seg) return null;
const tiff = seg.offset + 4 + 6;
const view = new DataView(b.buffer, b.byteOffset);
const le = b[tiff] === 0x49;
const u16 = (at: number) => view.getUint16(tiff + at, le);
const u32 = (at: number) => view.getUint32(tiff + at, le);
const ascii = (at: number, n: number) => readTag(b, tiff + at, n);
const ifd = u32(4);
const out: ExifSummary = { orientation: 1, hasGps: false };
for (let k = 0, n = u16(ifd); k < n; k++) {
const e = ifd + 2 + k * 12;
const tag = u16(e);
const count = u32(e + 4);
const value = count <= 4 ? e + 8 : u32(e + 8);
if (tag === 0x0112) out.orientation = u16(e + 8);
else if (tag === 0x010f) out.make = ascii(value, count);
else if (tag === 0x0110) out.model = ascii(value, count);
else if (tag === 0x8825) out.hasGps = true;
}
return out;
}
export interface BudgetResult {
blob: Blob;
quality: number;
/** số lần gọi encoder */
encodes: number;
}
export function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
/**
* Tìm chất lượng cao nhất mà file vẫn ≤ maxBytes bằng tìm kiếm nhị phân (tối đa 7 lần encode),
* thay vì thử lần lượt 0.9, 0.8, … như bản gốc. Không vừa ở chất lượng thấp nhất thì trả null.
*/
export async function encodeWithinBudget(
canvas: HTMLCanvasElement,
maxBytes: number,
type = 'image/jpeg',
{ min = 0.1, max = 0.92, steps = 6 } = {},
): Promise<BudgetResult | null> {
let encodes = 0;
const at = async (q: number) => {
encodes++;
return canvasToBlob(canvas, type, q);
};
const top = await at(max);
if (top && top.size <= maxBytes) return { blob: top, quality: max, encodes };
let best: { blob: Blob; quality: number } | null = null;
let lo = min;
let hi = max;
for (let i = 0; i < steps; i++) {
const q = i === 0 ? lo : (lo + hi) / 2;
const blob = await at(q);
if (blob && blob.size <= maxBytes) {
best = { blob, quality: q };
lo = q;
if (blob.size >= maxBytes * 0.9) break; // đã sát ngân sách: tăng chất lượng thêm cũng không đáng
} else if (i === 0) {
return null; // chất lượng thấp nhất còn quá lớn: phải thu nhỏ kích thước
} else hi = q;
}
return best && { ...best, encodes };
}
/**
* Micro-thumbnail: mỗi lần chưa vừa thì thu nhỏ còn một nửa. Khác bản gốc: tôn trọng mimetype,
* không bao giờ tạo canvas 0 px (toBlob của canvas rỗng trả null).
*/
export async function generateMicroThumb(
source: HTMLCanvasElement,
maxBytes: number,
{ mimetype = 'image/jpeg', maxAttempts = 10, quality = 0.8 } = {},
): Promise<{ blob: Blob; width: number; height: number; attempts: number }> {
let canvas = source;
let blob = await canvasToBlob(canvas, mimetype, quality);
let attempts = 0;
for (let scale = 0.5; attempts < maxAttempts && (!blob || blob.size > maxBytes); scale /= 2) {
attempts++;
const w = Math.max(1, Math.floor(source.width * scale));
const h = Math.max(1, Math.floor(source.height * scale));
canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')!.drawImage(source, 0, 0, w, h);
blob = await canvasToBlob(canvas, mimetype, quality);
if (w === 1 && h === 1) break;
}
if (!blob) throw new Error(`trình duyệt không encode được ${mimetype}`);
return { blob, width: canvas.width, height: canvas.height, attempts };
}
/** Toạ độ lấy mẫu dạng lưới phủ cả khung hình (gồm cả giữa ảnh, nơi letterbox không che). */
export function gridSamplePoints(width: number, height: number, n = 6): [number, number][] {
const pts: [number, number][] = [];
for (let r = 0; r < n; r++)
for (let c = 0; c < n; c++) pts.push([Math.floor(((c + 0.5) * width) / n), Math.floor(((r + 0.5) * height) / n)]);
return pts;
}
/** Frame "đen" khi mọi điểm mẫu đều gần đen. Ngưỡng nhỏ để chịu được nhiễu nén của video. */
export function isBlackFrame(img: ImageData, n = 6, threshold = 16): boolean {
return gridSamplePoints(img.width, img.height, n).every(([x, y]) => {
const i = 4 * (y * img.width + x);
return img.data[i] <= threshold && img.data[i + 1] <= threshold && img.data[i + 2] <= threshold;
});
}
ts
#Đi qua code
listJpegSegmentstrả mảng{ marker, name, offset, size, tag }. Tổngsizecủa mọi segment bằng đúng độ dài file (test kiểm điều này), nênstripJpegMetadatachỉ việc ghép các látsubarraycủa segment được giữ.readExifSummaryđọc IFD0 của Exif: byte order (IIhayMM), số entry, rồi từng entry 12 byte. Chỉ lấyOrientation(0x0112),Make,Modelvà có hay không con trỏ GPS (0x8825). Đủ để quyết định "có cần xoay không" trước khi xoá.encodeWithinBudgetthử chất lượng cao nhất trước (đa số ảnh nhỏ vừa ngay). Không vừa thì thử thấp nhất: vẫn không vừa thì trảnullsớm để người gọi thu nhỏ kích thước. Còn lại chia đôi khoảng[lo, hi]tối đa 5 lần (độ chính xác khoảng 0.03), dừng sớm khi file đã đạt 90% ngân sách.generateMicroThumbgiữ ý tưởng chia đôi kích thước của bản gốc nhưng truyềnmimetypethật vàotoBlobvà kẹp kích thước tối thiểu 1 px.isBlackFramelấy 36 điểm ở tâm các ô lưới 6×6, so với ngưỡng 16 thay vì đúng 0, vì frame đen của video nén hiếm khi đúng0,0,0.
Khác bản gốc:
- Giữ APP2
ICC_PROFILEvà APP14Adobethay vì ném lỗi rồi re-encode. Bỏ APP2 loại khác (MPF), APP3–APP15 còn lại, kể cả APP0 không phảiJFIF(JFXX chứa thumbnail). - Không đòi JFIF hay Exif đứng đầu. JPEG xuất từ một số công cụ không có cả hai và vẫn hợp lệ.
- Giữ nguyên APP0 JFIF gốc thay vì dựng lại. Bản gốc dựng lại 18 byte để chắc chắn bỏ được thumbnail nhúng trong JFIF; nếu cần, bạn cũng có thể làm vậy.
generateMicroThumbgốc nhậnmimetypenhưng hàmO(canvas, type)bên trong bỏ qua tham số, luôn gọitoBlob(cb, 'image/jpeg'), còndataUrltrả về cũng luôn là JPEG. Hiện chỉ có một nơi gọi và truyền đúng JPEG nên lỗi chưa lộ ra. Ngoài ra ảnh rất dẹt (vd 4000×6) sau vài lần chia đôi có chiều cao 0:toBlobcủa canvas 0 px trảnullvà dòngc.sizenémTypeError.- Kiểm frame đen bằng lưới phủ cả khung và ngưỡng nhỏ.
#Tại sao không làm cách khác?
- Luôn re-encode qua canvas: đơn giản và chắc chắn xoá hết metadata, nhưng mất chất lượng, tốn CPU với ảnh 12 MP, và hay làm file to lên. Đi đường segment cho trường hợp phổ biến nhất (ảnh đã nhỏ, không cần xoay) là đáng.
- Sửa tag Exif thay vì xoá (vd chỉ xoá GPS, giữ Orientation): làm được, nhưng phải viết lại cấu trúc IFD có con trỏ offset, dễ sai. Bỏ cả APP1 rồi xoay điểm ảnh khi cần thì đơn giản hơn.
- Thử chất lượng tuyến tính như bản gốc: dễ hiểu, và khi ảnh chỉ cần hạ nhẹ (q khoảng 0.6–0.9) thì còn ít lần encode hơn tìm nhị phân (demo với ngân sách 8 000 B: 5 lần so với 7). Nhưng ngân sách càng chặt càng tốn, tới 11 lần, và bước 0.1 thô: ảnh có thể chỉ cần q = 0.64 mà bị hạ xuống 0.6. Kích thước file tăng gần như đơn điệu theo chất lượng, đúng điều kiện để tìm nhị phân; dừng sớm khi đã đạt 90% ngân sách thì đỡ phí lượt.
- Đo kích thước bằng độ dài base64: nhanh hơn giải base64 (
Math.floor(len * 3 / 4) - số dấu =), nhưng dùngtoBlobthì có kích thước thật luôn, lại không phải giữ chuỗi base64 trong bộ nhớ.
#Edge case và cạm bẫy
- Orientation lật gương (giá trị 2, 4, 5, 7): bản gốc chỉ xử lý ba hướng
right-top,left-bottom,bottom-right, các hướng lật gương được coi là "không cần xoay" và đi đườngcleanJPEG, mất luôn thông tin lật. Hãy chỉ xoá metadata khiorientation === 1. - Trình duyệt đã tự xoay: từ khoảng năm 2020, các trình duyệt lớn mặc định áp dụng Exif Orientation khi hiển thị
<img>(image-orientation: from-image), và ở phần lớn trình duyệt cả khi vẽ<img>lên canvas. Code tự xoay thêm một lần nữa có thể làm ảnh bị xoay hai lần; có lẽ phần xoay thủ công của bản 2020 viết cho trình duyệt cũ. Kiểm tra bằng ảnh thật trước khi tự xoay. - Dữ liệu sau EOI: ảnh "Live Photo", ảnh có gain map HDR hay ảnh độ sâu thường gắn thêm ảnh phụ sau
FFD9(khai báo trong APP2 MPF). Cả bản gốc và bản viết lại dừng ở EOI đầu tiên nên các ảnh phụ bị bỏ, vừa nhỏ file vừa đỡ lộ thông tin. - Byte đệm: chuẩn JPEG cho phép nhiều
0xFFtrước marker. Bản gốc đọcFF FFnhư marker0xFFvà ném"Did not understand marker", rồi rơi về re-encode. Không sai, chỉ phí. - Ngân sách quá nhỏ: ảnh lớn ở chất lượng 0.1 vẫn có thể vượt 20 KB. Bản gốc khi đó gửi tin nhắn không có preview. Nên thu nhỏ thêm kích thước rồi thử lại, như
generateMicroThumb.
#Dùng trong dự án
Form tải ảnh đại diện, ảnh sản phẩm, ảnh đính kèm: xoá metadata ngay trên trình duyệt trước khi upload.
const bytes = new Uint8Array(await file.arrayBuffer());
const exif = readExifSummary(bytes);
const clean = !exif || exif.orientation === 1 ? stripJpegMetadata(bytes).bytes : await reencodeUpright(file);
const thumb = await encodeWithinBudget(smallCanvas, 20_000);
ts
Server vẫn nên xoá lại lần nữa: client có thể bị bỏ qua. Ý tưởng "gửi kèm một bản xem trước rất nhỏ" cũng có ở thumbnail siêu nhỏ của Telegram, nơi ảnh preview chỉ vài trăm byte.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { listJpegSegments, stripJpegMetadata, readExifSummary, isBlackFrame } from '../src/lib/waJpegTools';
const exif = new Uint8Array(readFileSync('public/wa-exif.jpg'));
const prog = new Uint8Array(readFileSync('public/wa-progressive.jpg'));
describe('waJpegTools', () => {
it('tổng kích thước segment bằng file, progressive có 10 SOS', () => {
for (const f of [exif, prog]) expect(listJpegSegments(f).reduce((n, s) => n + s.size, 0)).toBe(f.length);
expect(listJpegSegments(prog).filter((s) => s.name === 'SOS').length).toBe(10);
});
it('đọc Exif: có GPS, orientation 1', () => {
const e = readExifSummary(exif)!;
expect(e.hasGps).toBe(true);
expect(e.orientation).toBe(1);
});
it('xoá Exif + COM, giữ ICC, chịu byte đệm FF', () => {
const icc = [0xff, 0xe2, 0, 16, ...new TextEncoder().encode('ICC_PROFILE\0'), 1, 1];
const input = new Uint8Array([...exif.subarray(0, 2), 0xff, 0xff, ...icc, ...exif.subarray(2)]);
const { bytes, removed } = stripJpegMetadata(input);
expect(removed.map((s) => s.name).sort()).toEqual(['APP1', 'COM']);
expect(readExifSummary(bytes)).toBeNull();
expect(listJpegSegments(bytes).some((s) => s.tag === 'ICC_PROFILE')).toBe(true);
});
it('frame có viền đen trên dưới không bị coi là đen', () => {
const w = 60, h = 40, data = new Uint8ClampedArray(w * h * 4);
for (let y = 12; y < 28; y++) for (let x = 0; x < w; x++) data.set([200, 120, 40, 255], 4 * (y * w + x));
expect(isBlackFrame({ width: w, height: h, data } as ImageData)).toBe(false);
data.fill(0);
expect(isBlackFrame({ width: w, height: h, data } as ImageData)).toBe(true);
});
});
ts
Kiểm "điểm ảnh không đổi" cần bộ giải mã ảnh: trong demo dùng createImageBitmap + getImageData, ngoài trình duyệt có thể dùng Pillow ((np.array(a) != np.array(b)).sum() == 0).
#So với chuẩn web và thư viện
- Không có API chuẩn để xoá metadata. Thư viện như
piexifjs(sửa/xoá Exif) hayexifr(đọc Exif nhanh) làm việc trên byte giống cách ở đây. Phía server,sharpmặc định bỏ metadata khi xuất ảnh trừ khi gọiwithMetadata(). canvas.toBlob(cb, type, quality): mọi trình duyệt hỗ trợ JPEG và PNG; WebP thì Chrome, Edge, Firefox có, Safari thì nên kiểm tra lại (tính tới 10/2026). Luôn kiểmblob.typesau khi encode: trình duyệt không hỗ trợ định dạng yêu cầu sẽ âm thầm trả PNG.OffscreenCanvas.convertToBloblàm được việc tương tự trong Web Worker, tránh chặn luồng chính khi nén nhiều ảnh.image-orientation: from-imagelà mặc định ở Chrome, Firefox và Safari hiện tại; xem caniuse.requestVideoFrameCallback(Chrome, Edge, Safari; Firefox từ bản 132, tính tới 10/2026) báo khi một frame video thật sự đã được vẽ, thay cho việc đoán bằng cách thử lại theo thời gian.
Bài này thuộc series Học từ mã nguồn WhatsApp Web (19 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: media và mã hoá
- #1 Giải mã JPEG progressive theo từng scan, kiểm HMAC
- #2 Ghép khoảng byte cho video streaming
- #3 Xoá EXIF không re-encode, thumbnail theo ngân sách byte (bài này)
- #4 Đọc metadata sticker WebP động
Phần 2: lưu trữ trên trình duyệt
- #5 Kho media LRU tự co theo quota
- #6 Cache blob trong RAM có refcount, tự revoke objectURL
- #7 Khoá IndexedDB giữ đúng thứ tự từ điển
Phần 3: danh sách tin nhắn
- #8 Render tin nhắn theo cửa sổ chiều cao ước lượng
- #9 Theo dõi viewport tin nhắn: binary search, ngày dính
- #10 FlatList tái dùng key React, overscan bất đối xứng
Phần 4: soạn tin và chữ
- #11 Emoticon thành emoji bằng trie đọc ngược, tìm emoji
- #12 Mention bằng token zero-width, giới hạn theo code point
- #13 Xác định hướng chữ bằng bảng delta nén
- #14 Phát hiện homograph trong link, chặn giả đuôi file RTLO
Phần 5: realtime và tiện ích
Code + demo: examples/web-patterns · Mở trên StackBlitz
