LUMEN: generative shader studio with 9 looping art modes and PNG/video/GIF export
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+241
@@ -0,0 +1,241 @@
|
||||
/* 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user