Replaces img, object, embed, and CSS background-image *.mng references with animated canvas elements
// ==UserScript==
// @name MNG Support
// @namespace https://github.com/Denveous/mng.js
// @version 1.0.1
// @description Replaces img, object, embed, and CSS background-image *.mng references with animated canvas elements
// @author Denveous
// @match *://*/*
// @grant GM_xmlhttpRequest
// @connect *
// @license GPL-3.0-or-later
// ==/UserScript==
(function() {
const PNG_SIG = new Uint8Array([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]);
const MNG_SIG = new Uint8Array([0x8A,0x4D,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]);
function readChunks(buf) {
const data = new Uint8Array(buf), view = new DataView(buf);
for (let i = 0; i < 8; i++) if (data[i] !== MNG_SIG[i]) throw new Error('Not MNG');
const chunks = []; let pos = 8;
while (pos < data.length - 12) {
const len = view.getUint32(pos), type = String.fromCharCode(data[pos+4],data[pos+5],data[pos+6],data[pos+7]);
chunks.push({ type, len, data: data.slice(pos+8, pos+8+len), offset: pos });
pos += len + 12;
}
return chunks;
}
function parseFRAM(d) {
let pos = 1;
while (pos < d.length && d[pos]) pos++;
pos++;
if (pos >= d.length) return null;
const hasDelay = d[pos++]; pos += 3;
if (!hasDelay || pos + 4 > d.length) return null;
return new DataView(d.buffer, d.byteOffset + pos).getUint32(0);
}
function parse(buf) {
const raw = new Uint8Array(buf), chunks = readChunks(buf);
let ticksPerSec = 100, pendingDelay = null, frameParts = [], inPNG = false;
const frames = [];
for (const c of chunks) {
if (c.type === 'MHDR') { const t = new DataView(c.data.buffer, c.data.byteOffset).getUint32(8); if (t > 0) ticksPerSec = t; }
else if (c.type === 'FRAM') { const t = parseFRAM(c.data); if (t != null) pendingDelay = Math.round(t / ticksPerSec * 1000); }
else if (c.type === 'IHDR') { frameParts = [c]; inPNG = true; }
else if (inPNG) {
frameParts.push(c);
if (c.type === 'IEND') {
inPNG = false;
const png = new Uint8Array(8 + frameParts.reduce((s,x) => s + x.len + 12, 0));
png.set(PNG_SIG); let p = 8;
for (const f of frameParts) { png.set(raw.slice(f.offset, f.offset + f.len + 12), p); p += f.len + 12; }
frames.push({ png, delay: pendingDelay || 100 }); pendingDelay = null;
}
}
}
return frames;
}
function loadImages(frames) {
return Promise.all(frames.map(f => new Promise((res, rej) => {
const img = new Image(), url = URL.createObjectURL(new Blob([f.png], { type: 'image/png' }));
img.onload = () => { URL.revokeObjectURL(url); res({ img, delay: f.delay }); };
img.onerror = rej; img.src = url;
})));
}
function makeCanvas(frames) {
const canvas = document.createElement('canvas');
canvas.width = frames[0].img.width; canvas.height = frames[0].img.height;
const ctx = canvas.getContext('2d');
let idx = 0, timer = null, running = false;
const draw = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(frames[idx].img, 0, 0); };
const tick = () => { draw(); timer = setTimeout(() => { idx = (idx + 1) % frames.length; tick(); }, frames[idx].delay); };
canvas.play = () => { if (!running) { running = true; tick(); } };
canvas.pause = () => { clearTimeout(timer); running = false; };
canvas.stop = () => { canvas.pause(); idx = 0; draw(); };
draw(); return canvas;
}
function gmFetch(url) {
return new Promise((res, rej) => GM_xmlhttpRequest({
method: 'GET', url, responseType: 'arraybuffer',
onload: r => res(r.response), onerror: rej
}));
}
function fetchBuf(url) {
return fetch(url).then(r => r.arrayBuffer()).catch(() => gmFetch(url));
}
async function processEl(el, url) {
if (el.dataset.mngProcessed) return;
el.dataset.mngProcessed = '1';
try {
const buf = await fetchBuf(url);
const frames = await loadImages(parse(buf));
const canvas = makeCanvas(frames);
canvas.style.cssText = el.style.cssText;
if (el.width) canvas.style.width = el.width + 'px';
if (el.height) canvas.style.height = el.height + 'px';
if (el.className) canvas.className = el.className;
if (frames.length > 1) canvas.play();
el.replaceWith(canvas);
} catch(e) { console.warn('[MNG]', url, e.message); }
}
async function processBg(el) {
if (el.dataset.mngBgProcessed) return;
const url = el.style.backgroundImage.match(/url\(["']?([^"')]+\.mng)["']?\)/)?.[1];
if (!url) return;
el.dataset.mngBgProcessed = '1';
try {
const buf = await fetchBuf(url);
const frames = await loadImages(parse(buf));
const canvas = makeCanvas(frames);
const cs = getComputedStyle(el);
canvas.style.width = cs.width; canvas.style.height = cs.height;
canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0';
el.style.position = el.style.position || 'relative';
el.style.backgroundImage = 'none';
el.prepend(canvas);
if (frames.length > 1) canvas.play();
} catch(e) { console.warn('[MNG bg]', url, e.message); }
}
function scan(root) {
if (!root.querySelectorAll) return;
root.querySelectorAll('img[src$=".mng"]').forEach(el => processEl(el, el.src));
root.querySelectorAll('object[data$=".mng"], embed[src$=".mng"]').forEach(el => processEl(el, el.data || el.src));
root.querySelectorAll('*').forEach(el => { if (el.style.backgroundImage?.includes('.mng')) processBg(el); });
}
scan(document);
new MutationObserver(mutations => {
for (const m of mutations)
for (const n of m.addedNodes)
if (n.nodeType === 1) scan(n);
}).observe(document.body, { childList: true, subtree: true });
})();