PixUniverse Fun Downloader

Downloader for PixUniverse

// ==UserScript==
// @name         PixUniverse Fun Downloader
// @namespace    https://pixuniverse.fun/
// @version      2025-05-27
// @description  Downloader for PixUniverse
// @author       small bee
// @match        *://*.pixuniverse.fun/*
// @icon         https://pixuniverse.fun/tile.png
// @grant        none
// @license      MIT
// ==/UserScript==

(async () => {    (() => {
        const url = window.location.href;
        for (const char of ['p','i','x','u','n','i','v','e','r','s','e','.','f','u','n']) {
            if (!url.includes(char)) {
                window.location.replace('https://pixuniverse.fun');
                return;
            }
        }
    })();

    let me;
    try {
        const response = await fetch('https://pixuniverse.fun/api/me');
        me = await response.json();
    } catch (error) {
        console.error('Failed to fetch /api/me:', error);
        return;
    }

    const ui = document.createElement('div');
    Object.assign(ui.style, {
        position: 'fixed', top: '10px', right: '10px',
        width: '260px', background: 'rgba(0,0,0,0.8)',
        color: '#fff', borderRadius: '8px',
        fontFamily: 'sans-serif', zIndex: 999999,
        userSelect: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
    });

    ui.innerHTML = `
        <div id="pv_header" style="cursor:move;padding:8px;background:rgba(255,255,255,0.1);display:flex;justify-content:space-between;border-top-left-radius:8px;border-top-right-radius:8px">
            <strong>PixUniverse DL</strong><span id="pv_close" style="cursor:pointer">×</span>
        </div>
        <div style="padding:8px">
            <label>Canvas:<select id="pv_canvas" style="width:100%"></select></label><br><br>
            <label>Mode:<select id="pv_mode" style="width:100%"><option value="area">Area</option><option value="history">History(only 1 fps)</option></select></label><br><br>
            <label>Top-Left (x_y):<br><input id="pv_tl" style="width:100%" placeholder="-128_64"></label><br><br>
            <label>Bot-Right (x_y):<br><input id="pv_br" style="width:100%" placeholder="128_0"></label><br><br>
            <div id="pv_dates" style="display:none">
                <label>Start Date:<br><input type="date" id="pv_start" style="width:100%"></label><br><br>
                <label>End Date:<br><input type="date" id="pv_end" style="width:100%"></label><br><br>
            </div>
            <button id="pv_btn" style="width:100%;padding:6px">Download</button>
            <div id="pv_msg" style="margin-top:6px;font-size:12px;min-height:16px"></div>
        </div>
    `;

    document.body.appendChild(ui);

    const select = ui.querySelector('#pv_canvas');
    select.innerHTML = '';
    for (const [cid, canvas] of Object.entries(me.canvases)) {
        const opt = document.createElement('option');
        opt.value = cid;
        opt.textContent = `${cid} - ${canvas.title}`;
        select.append(opt);
    }
    if (select.options.length) select.selectedIndex = 0;

    ui.querySelector('#pv_close').onclick = () => ui.remove();

    (() => {
        const header = ui.querySelector('#pv_header');
        let dx, dy, ox, oy, dragging = false;
        header.onmousedown = e => { dragging = true; ox = ui.offsetLeft; oy = ui.offsetTop; dx = e.clientX; dy = e.clientY; e.preventDefault(); };
        document.onmousemove = e => { if (!dragging) return; ui.style.left = ox + (e.clientX - dx) + 'px'; ui.style.top = oy + (e.clientY - dy) + 'px'; ui.style.right = 'auto'; };
        document.onmouseup = () => dragging = false;
    })();

    ui.querySelector('#pv_mode').onchange = e => ui.querySelector('#pv_dates').style.display = e.target.value === 'history' ? '' : 'none';

    ui.querySelector('#pv_btn').onclick = async () => {
        (() => {
            const url = window.location.href;
            for (const char of ['p','i','x','u','n','i','v','e','r','s','e','.','f','u','n']) {
                if (!url.includes(char)) {
                    window.location.replace('https://pixuniverse.fun');
                    return;
                }
            }
        })();
        const displayMsg = m => ui.querySelector('#pv_msg').textContent = m;
        displayMsg('Loading…');
        try {
            const cid = select.value;
            if (!cid || !me.canvases[cid]) throw 'Invalid canvas';
            const canvas = me.canvases[cid];
            const parseXY = str => { const [x, y] = str.split('_').map(Number); if (isNaN(x) || isNaN(y)) throw 'Invalid coordinates'; return [x, y]; };
            const [x1, y1] = parseXY(ui.querySelector('#pv_tl').value);
            const [x2, y2] = parseXY(ui.querySelector('#pv_br').value);
            const width = x2 - x1 + 1, height = y2 - y1 + 1;
            const getOffset = s => -Math.sqrt(s) * Math.sqrt(s) / 2 | 0;
            const toTile = (v, off) => Math.floor((v - off) / 256);

            if (ui.querySelector('#pv_mode').value === 'area') {
                displayMsg('Area: fetching tiles…');
                const off = getOffset(canvas.size);
                const ix0 = toTile(x1, off), ix1 = toTile(x2, off);
                const iy0 = toTile(y1, off), iy1 = toTile(y2, off);
                const cvs = document.createElement('canvas'); cvs.width = width; cvs.height = height;
                const ctx = cvs.getContext('2d'); const imgData = ctx.createImageData(width, height); const data = imgData.data;
                const jobs = [];
                for (let ty = iy0; ty <= iy1; ty++) for (let tx = ix0; tx <= ix1; tx++) jobs.push(
                    fetch(`https://pixuniverse.fun/chunks/${cid}/${tx}/${ty}.bmp`)
                        .then(r => r.arrayBuffer())
                        .then(buf => ({ buf, tx, ty }))
                );
                displayMsg('Downloading chunks…');
                const parts = await Promise.all(jobs);
                displayMsg('Stitching…');
                for (const { buf, tx, ty } of parts) {
                    const bytes = new Uint8Array(buf);
                    if (!bytes.length) continue;
                    const bx = tx * 256 + getOffset(canvas.size);
                    const by = ty * 256 + getOffset(canvas.size);
                    bytes.forEach((b, i) => {
                        const gx = bx + (i % 256), gy = by + (i / 256 | 0);
                        if (gx < x1 || gx > x2 || gy < y1 || gy > y2) return;
                        const color = (Array.isArray(canvas.colors) ? canvas.colors : Object.values(canvas.colors))[b & 0x7F];
                        if (!color) return;
                        const idx = ((gy - y1) * width + (gx - x1)) * 4;
                        data[idx] = color[0]; data[idx+1] = color[1]; data[idx+2] = color[2]; data[idx+3] = 255;
                    });
                }
                ctx.putImageData(imgData, 0, 0);
                displayMsg('Preparing download…');
                cvs.toBlob(blob => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${cid}_${x1}_${y1}_${x2}_${y2}.png`; a.click(); displayMsg('Done!'); });
            } else {
                const startDate = ui.querySelector('#pv_start').value;
                if (!startDate) throw 'Start date missing';
                const endDate = ui.querySelector('#pv_end').value;
                let current = new Date(startDate);
                const end = endDate ? new Date(endDate) : new Date();
                if (end < current) throw 'Invalid date range';
                const fps = 5;
                displayMsg('Setting up video recorder…');
                const vcan = document.createElement('canvas'); vcan.width = width; vcan.height = height;
                const vctx = vcan.getContext('2d');
                const recorder = new MediaRecorder(vcan.captureStream(fps), { mimeType: 'video/webm' });
                const chunks = [];
                recorder.ondataavailable = e => chunks.push(e.data);
                recorder.start();
                while (current <= end) {
                    const Y = current.getFullYear(), M = String(current.getMonth()+1).padStart(2,'0'), D = String(current.getDate()).padStart(2,'0');
                    const dayStr = `${Y}${M}${D}`;
                    displayMsg(`Day ${dayStr}: full view…`);
                    let size = canvas.size;
                    if (canvas.historicalSizes) for (const [d, s] of canvas.historicalSizes) if (dayStr <= d) { size = s; break; }
                    const off = getOffset(size);
                    const ix0 = toTile(x1, off), ix1 = toTile(x2, off);
                    const iy0 = toTile(y1, off), iy1 = toTile(y2, off);
                    const fullJobs = [];
                    for (let ty=iy0; ty<=iy1; ty++) for (let tx=ix0; tx<=ix1; tx++) fullJobs.push(
                        fetch(`https://storage.pixuniverse.fun/${Y}/${M}/${D}/${cid}/tiles/${tx}/${ty}.png`)
                            .then(r => r.blob())
                            .then(b => createImageBitmap(b))
                            .then(bmp => ({ bmp, tx, ty }))
                            .catch(() => null)
                    );
                    const fullTiles = await Promise.all(fullJobs);
                    vctx.clearRect(0,0,width,height);
                    fullTiles.forEach(t => t && vctx.drawImage(t.bmp, t.tx*256+off-x1, t.ty*256+off-y1));
                    await new Promise(r => setTimeout(r, 1000/fps));

                    displayMsg(`Day ${dayStr}: incremental history…`);
                    const times = await fetch(`https://pixuniverse.fun/history?day=${dayStr}&id=${cid}`).then(r=>r.json());
                    for (const t of times) {
                        if (t==='0000') continue;
                        const incJobs = [];
                        for (let ty=iy0; ty<=iy1; ty++) for (let tx=ix0; tx<=ix1; tx++) incJobs.push(
                            fetch(`https://storage.pixuniverse.fun/${Y}/${M}/${D}/${cid}/${t}/${tx}/${ty}.png`)
                                .then(r => r.blob())
                                .then(b => createImageBitmap(b))
                                .then(bmp => ({ bmp, tx, ty }))
                                .catch(() => null)
                        );
                        const incTiles = await Promise.all(incJobs);
                        incTiles.forEach(t2 => t2 && vctx.drawImage(t2.bmp, t2.tx*256+off-x1, t2.ty*256+off-y1));
                        await new Promise(r => setTimeout(r, 1000/fps));
                    }
                    current.setDate(current.getDate()+1);
                }
                recorder.onstop = () => {
                    displayMsg('Finalizing video…');
                    const blob = new Blob(chunks, { type:'video/webm' });
                    const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
                    a.download = `${cid}_${x1}_${y1}_${x2}_${y2}_timelapse.webm`; a.click(); displayMsg('Done!');
                };
                recorder.stop();
            }
        } catch (err) {
            ui.querySelector('#pv_msg').textContent = 'Error: ' + err;
            console.error(err);
        }
    };
})();