10d8bd9902
Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
7.9 KiB
JavaScript
242 lines
7.9 KiB
JavaScript
/* Dependency-free animated GIF encoder.
|
|
- global 256-color palette via median cut over sampled frame pixels
|
|
- optional 4x4 Bayer ordered dithering (kills gradient banding)
|
|
- standard GIF LZW (jsgif-compatible code-size handling)
|
|
- NETSCAPE2.0 extension for infinite looping */
|
|
|
|
var GIFEnc = (function () {
|
|
|
|
/* ---------- byte writer ---------- */
|
|
function ByteWriter() {
|
|
this.chunks = [];
|
|
this.cur = new Uint8Array(1 << 16);
|
|
this.len = 0;
|
|
}
|
|
ByteWriter.prototype.byte = function (b) {
|
|
if (this.len === this.cur.length) { this.chunks.push(this.cur); this.cur = new Uint8Array(1 << 16); this.len = 0; }
|
|
this.cur[this.len++] = b & 0xff;
|
|
};
|
|
ByteWriter.prototype.bytes = function (arr) {
|
|
for (var i = 0; i < arr.length; i++) this.byte(arr[i]);
|
|
};
|
|
ByteWriter.prototype.short = function (v) { this.byte(v & 0xff); this.byte((v >> 8) & 0xff); };
|
|
ByteWriter.prototype.string = function (s) { for (var i = 0; i < s.length; i++) this.byte(s.charCodeAt(i)); };
|
|
ByteWriter.prototype.result = function () {
|
|
var total = this.chunks.length * (1 << 16) + this.len;
|
|
var out = new Uint8Array(total);
|
|
var o = 0;
|
|
for (var i = 0; i < this.chunks.length; i++) { out.set(this.chunks[i], o); o += this.chunks[i].length; }
|
|
out.set(this.cur.subarray(0, this.len), o);
|
|
return out;
|
|
};
|
|
|
|
/* ---------- median cut quantization ---------- */
|
|
function buildPalette(samples, maxColors) {
|
|
// samples: array of [r,g,b]
|
|
var boxes = [samples];
|
|
while (boxes.length < maxColors) {
|
|
var bi = -1, bRange = -1, bCh = 0;
|
|
for (var i = 0; i < boxes.length; i++) {
|
|
var box = boxes[i];
|
|
if (box.length < 2) continue;
|
|
var mins = [255, 255, 255], maxs = [0, 0, 0];
|
|
for (var j = 0; j < box.length; j++) {
|
|
for (var c = 0; c < 3; c++) {
|
|
if (box[j][c] < mins[c]) mins[c] = box[j][c];
|
|
if (box[j][c] > maxs[c]) maxs[c] = box[j][c];
|
|
}
|
|
}
|
|
for (var c2 = 0; c2 < 3; c2++) {
|
|
var r = maxs[c2] - mins[c2];
|
|
if (r > bRange) { bRange = r; bi = i; bCh = c2; }
|
|
}
|
|
}
|
|
if (bi < 0 || bRange === 0) break;
|
|
var target = boxes[bi];
|
|
target.sort(function (a, b) { return a[bCh] - b[bCh]; });
|
|
var mid = target.length >> 1;
|
|
boxes.splice(bi, 1, target.slice(0, mid), target.slice(mid));
|
|
}
|
|
var palette = [];
|
|
for (var k = 0; k < boxes.length; k++) {
|
|
var bx = boxes[k];
|
|
if (!bx.length) continue;
|
|
var rs = 0, gs = 0, bs = 0;
|
|
for (var m = 0; m < bx.length; m++) { rs += bx[m][0]; gs += bx[m][1]; bs += bx[m][2]; }
|
|
palette.push([Math.round(rs / bx.length), Math.round(gs / bx.length), Math.round(bs / bx.length)]);
|
|
}
|
|
while (palette.length < maxColors) palette.push([0, 0, 0]);
|
|
return palette;
|
|
}
|
|
|
|
function makeNearest(palette) {
|
|
var cache = new Int16Array(32768).fill(-1);
|
|
return function (r, g, b) {
|
|
var key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
|
|
var hit = cache[key];
|
|
if (hit >= 0) return hit;
|
|
var best = 0, bd = 1e9;
|
|
for (var i = 0; i < palette.length; i++) {
|
|
var dr = palette[i][0] - r, dg = palette[i][1] - g, db = palette[i][2] - b;
|
|
var d = dr * dr + dg * dg + db * db;
|
|
if (d < bd) { bd = d; best = i; }
|
|
}
|
|
cache[key] = best;
|
|
return best;
|
|
};
|
|
}
|
|
|
|
var BAYER = [
|
|
0, 8, 2, 10,
|
|
12, 4, 14, 6,
|
|
3, 11, 1, 9,
|
|
15, 7, 13, 5
|
|
];
|
|
|
|
/* ---------- LZW (GIF variant) ---------- */
|
|
function lzwEncode(minCodeSize, indices, out) {
|
|
var clearCode = 1 << minCodeSize;
|
|
var eoiCode = clearCode + 1;
|
|
var nextCode = eoiCode + 1;
|
|
var curBits = minCodeSize + 1;
|
|
var maxCode = (1 << curBits) - 1;
|
|
var dict = new Map();
|
|
|
|
var bitAcc = 0, bitCnt = 0;
|
|
var sub = new Uint8Array(255), subLen = 0;
|
|
|
|
function flushSub() {
|
|
if (subLen > 0) { out.byte(subLen); for (var i = 0; i < subLen; i++) out.byte(sub[i]); subLen = 0; }
|
|
}
|
|
function pushByte(b) {
|
|
sub[subLen++] = b;
|
|
if (subLen === 255) flushSub();
|
|
}
|
|
function emit(code) {
|
|
bitAcc |= code << bitCnt;
|
|
bitCnt += curBits;
|
|
while (bitCnt >= 8) { pushByte(bitAcc & 0xff); bitAcc >>= 8; bitCnt -= 8; }
|
|
if (nextCode > maxCode && curBits < 12) {
|
|
curBits++;
|
|
maxCode = (1 << curBits) - 1;
|
|
}
|
|
}
|
|
|
|
out.byte(minCodeSize);
|
|
emit(clearCode);
|
|
|
|
var prev = indices[0];
|
|
for (var i = 1; i < indices.length; i++) {
|
|
var k = indices[i];
|
|
var key = (prev << 8) | k;
|
|
var hit = dict.get(key);
|
|
if (hit !== undefined) { prev = hit; continue; }
|
|
emit(prev);
|
|
if (nextCode < 4096) {
|
|
dict.set(key, nextCode++);
|
|
} else {
|
|
emit(clearCode);
|
|
dict.clear();
|
|
nextCode = eoiCode + 1;
|
|
curBits = minCodeSize + 1;
|
|
maxCode = (1 << curBits) - 1;
|
|
}
|
|
prev = k;
|
|
}
|
|
emit(prev);
|
|
emit(eoiCode);
|
|
if (bitCnt > 0) pushByte(bitAcc & 0xff);
|
|
flushSub();
|
|
out.byte(0); /* block terminator */
|
|
}
|
|
|
|
/* ---------- main encode ---------- */
|
|
/* frames: array of Uint8Array RGBA (top-down), all width*height*4 */
|
|
async function encode(opts) {
|
|
var frames = opts.frames, w = opts.width, h = opts.height;
|
|
var fps = opts.fps || 25;
|
|
var dither = opts.dither !== false;
|
|
var onProgress = opts.onProgress || function () {};
|
|
var isCancelled = opts.isCancelled || function () { return false; };
|
|
|
|
/* sample pixels across all frames for the global palette */
|
|
var samples = [];
|
|
var targetSamples = 42000;
|
|
var totalPx = frames.length * w * h;
|
|
var stride = Math.max(1, Math.floor(totalPx / targetSamples));
|
|
for (var f = 0; f < frames.length; f++) {
|
|
var d = frames[f];
|
|
for (var p = (f * 7919) % stride; p < w * h; p += stride) {
|
|
samples.push([d[p * 4], d[p * 4 + 1], d[p * 4 + 2]]);
|
|
}
|
|
}
|
|
onProgress(0.02, "building palette");
|
|
await microtask();
|
|
var palette = buildPalette(samples, 256);
|
|
var nearest = makeNearest(palette);
|
|
|
|
var out = new ByteWriter();
|
|
out.string("GIF89a");
|
|
out.short(w); out.short(h);
|
|
out.byte(0xF7); /* global table, 256 colors, 8-bit res */
|
|
out.byte(0); /* bg index */
|
|
out.byte(0); /* aspect */
|
|
for (var c = 0; c < 256; c++) {
|
|
var col = palette[c] || [0, 0, 0];
|
|
out.byte(col[0]); out.byte(col[1]); out.byte(col[2]);
|
|
}
|
|
|
|
/* NETSCAPE loop forever */
|
|
out.byte(0x21); out.byte(0xFF); out.byte(11);
|
|
out.string("NETSCAPE2.0");
|
|
out.byte(3); out.byte(1); out.short(0); out.byte(0);
|
|
|
|
var delay = Math.max(2, Math.round(100 / fps));
|
|
var indices = new Uint8Array(w * h);
|
|
|
|
for (var fi = 0; fi < frames.length; fi++) {
|
|
if (isCancelled()) return null;
|
|
var data = frames[fi];
|
|
var di = 0;
|
|
for (var y = 0; y < h; y++) {
|
|
for (var x = 0; x < w; x++) {
|
|
var o = (y * w + x) * 4;
|
|
var r = data[o], g = data[o + 1], b = data[o + 2];
|
|
if (dither) {
|
|
var dth = (BAYER[(y & 3) * 4 + (x & 3)] / 16 - 0.5) * 14;
|
|
r = Math.max(0, Math.min(255, r + dth));
|
|
g = Math.max(0, Math.min(255, g + dth));
|
|
b = Math.max(0, Math.min(255, b + dth));
|
|
}
|
|
indices[di++] = nearest(r | 0, g | 0, b | 0);
|
|
}
|
|
}
|
|
|
|
/* graphic control extension */
|
|
out.byte(0x21); out.byte(0xF9); out.byte(4);
|
|
out.byte(0x04); /* disposal: do not dispose */
|
|
out.short(delay);
|
|
out.byte(0); out.byte(0);
|
|
|
|
/* image descriptor */
|
|
out.byte(0x2C);
|
|
out.short(0); out.short(0); out.short(w); out.short(h);
|
|
out.byte(0); /* no local table */
|
|
|
|
lzwEncode(8, indices, out);
|
|
|
|
onProgress(0.05 + 0.95 * (fi + 1) / frames.length, "frame " + (fi + 1) + "/" + frames.length);
|
|
await microtask();
|
|
}
|
|
|
|
out.byte(0x3B); /* trailer */
|
|
return out.result();
|
|
}
|
|
|
|
function microtask() {
|
|
return new Promise(function (res) { setTimeout(res, 0); });
|
|
}
|
|
|
|
return { encode: encode };
|
|
})();
|