116 lines
4.5 KiB
JavaScript
116 lines
4.5 KiB
JavaScript
'use strict';
|
|
|
|
const zlib = require('zlib');
|
|
|
|
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
const crcTable = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let value = n;
|
|
for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
|
|
crcTable[n] = value >>> 0;
|
|
}
|
|
|
|
function crc32(buffer) {
|
|
let value = 0xffffffff;
|
|
for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
|
return (value ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function chunk(type, data) {
|
|
const name = Buffer.from(type, 'ascii');
|
|
const body = Buffer.from(data || []);
|
|
const out = Buffer.allocUnsafe(12 + body.length);
|
|
out.writeUInt32BE(body.length, 0);
|
|
name.copy(out, 4);
|
|
body.copy(out, 8);
|
|
out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length);
|
|
return out;
|
|
}
|
|
|
|
function paeth(left, above, upperLeft) {
|
|
const estimate = left + above - upperLeft;
|
|
const leftDistance = Math.abs(estimate - left);
|
|
const aboveDistance = Math.abs(estimate - above);
|
|
const cornerDistance = Math.abs(estimate - upperLeft);
|
|
return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left
|
|
: aboveDistance <= cornerDistance ? above : upperLeft;
|
|
}
|
|
|
|
function filterFrame(rgba, width, height) {
|
|
const stride = width * 4;
|
|
const result = Buffer.allocUnsafe((stride + 1) * height);
|
|
const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride));
|
|
for (let y = 0; y < height; y++) {
|
|
const rowStart = y * stride;
|
|
let bestType = 0;
|
|
let bestScore = Infinity;
|
|
for (let type = 0; type < 5; type++) {
|
|
const candidate = candidates[type];
|
|
let score = 0;
|
|
for (let x = 0; x < stride; x++) {
|
|
const raw = rgba[rowStart + x];
|
|
const left = x >= 4 ? rgba[rowStart + x - 4] : 0;
|
|
const above = y > 0 ? rgba[rowStart - stride + x] : 0;
|
|
const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0;
|
|
let predicted = 0;
|
|
if (type === 1) predicted = left;
|
|
else if (type === 2) predicted = above;
|
|
else if (type === 3) predicted = Math.floor((left + above) / 2);
|
|
else if (type === 4) predicted = paeth(left, above, upperLeft);
|
|
const filtered = (raw - predicted + 256) & 0xff;
|
|
candidate[x] = filtered;
|
|
score += Math.abs(filtered < 128 ? filtered : filtered - 256);
|
|
}
|
|
if (score < bestScore) { bestScore = score; bestType = type; }
|
|
}
|
|
const outputStart = y * (stride + 1);
|
|
result[outputStart] = bestType;
|
|
candidates[bestType].copy(result, outputStart + 1);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function encode(options) {
|
|
const width = Number(options.width);
|
|
const height = Number(options.height);
|
|
const frames = options.frames || [];
|
|
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效');
|
|
if (!frames.length) throw new TypeError('APNG 至少需要一帧');
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
|
|
const animationControl = Buffer.alloc(8);
|
|
animationControl.writeUInt32BE(frames.length, 0);
|
|
animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4);
|
|
const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)];
|
|
let sequence = 0;
|
|
for (let index = 0; index < frames.length; index++) {
|
|
const frame = frames[index];
|
|
const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength);
|
|
if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效');
|
|
const control = Buffer.alloc(26);
|
|
control.writeUInt32BE(sequence++, 0);
|
|
control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8);
|
|
control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16);
|
|
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20);
|
|
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22);
|
|
control[24] = 0; // keep the previous frame
|
|
control[25] = 0; // replace the full canvas
|
|
chunks.push(chunk('fcTL', control));
|
|
const compressed = zlib.deflateSync(filterFrame(rgba, width, height), {
|
|
level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)),
|
|
});
|
|
if (index === 0) chunks.push(chunk('IDAT', compressed));
|
|
else {
|
|
const frameData = Buffer.allocUnsafe(4 + compressed.length);
|
|
frameData.writeUInt32BE(sequence++, 0);
|
|
compressed.copy(frameData, 4);
|
|
chunks.push(chunk('fdAT', frameData));
|
|
}
|
|
}
|
|
chunks.push(chunk('IEND'));
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
module.exports = { encode };
|