Skip to content

Instantly share code, notes, and snippets.

@seleb
Last active May 4, 2025 06:57
Show Gist options
  • Save seleb/690228f38e3ef4e497760d646e6c8d8d to your computer and use it in GitHub Desktop.
Save seleb/690228f38e3ef4e497760d646e6c8d8d to your computer and use it in GitHub Desktop.
script for exporting roll20 maps to an image
/**
script for exporting roll20 maps to an image
how to use:
1. open your roll20 game to the page you want to save
2. open your browser's developer tools
3. copy-paste this entire file in the console
4. hit enter
5. wait for map to save and appear in top-left
6. right-click -> save as... to export
7. left-click to delete
notes:
- your UI will rapidly flash for a couple seconds when this is run,
as it is quickly scrolling through the map and saving it in chunks
- it's best to run this while at 100% zoom level.
it will automatically adjust if you aren't,
but sometimes the first chunk doesn't save properly anyway
- this script is unfortunately not 100% reliable,
as some assets may taint the canvas through CORS violations (i.e. technical reasons on roll20's end)
if this happens, you cannot save (even if you change pages) until they are removed and have refreshed.
of everything tested, icons attached to images on the map were the only things that caused this issue,
but i only tested a handful of free/web assets and no premium content
- very large maps may fail to save: this is dependent on your browser/hardware.
if you run into this problem, try a different browser (chrome seems to be most reliable),
try closing other tabs/apps to free up memory, try reducing the zoom level in the script,
or try making a smaller copy of the map so you can save it in chunks
- if you have any issues using this script, feel free to reach out! https://seans.site/#contact
*/
// main
async function saveMap() {
const frameRetries = 10;
const zoom = 100; // must be one of: 10, 50, 75, 100, 150, 200, or 250
const curZoom = Number(document.querySelector('#vm_zoom_buttons .level')?.textContent || '100') || 100;
const editorWrapper = document.querySelector('#editor-wrapper');
const isJumpGate = !!window.Campaign.view.model.engine;
try {
console.log('saving map...');
// close sidebar since it interferes
if (isJumpGate && !document.querySelector('body.sidebarhidden #rightsidebar')) {
document.querySelector('#sidebarcontrol').click();
}
// get total size
const gridCellSize = 70;
const scale = zoom / 100;
const page = window.Campaign.activePage();
const width = page.get('width') * gridCellSize * scale;
const height = page.get('height') * gridCellSize * scale;
// make a canvas to output to
const outputCanvas = document.createElement('canvas');
outputCanvas.width = width;
outputCanvas.height = height;
const ctx = outputCanvas.getContext('2d', { willReadFrequently: true });
const finalCanvas = document.querySelector('#babylonCanvas');
if (!finalCanvas) throw new Error("Could not find game canvas");
// set zoom to output size
await setZoom(zoom);
// add some extra padding so we can scroll through fully
const editor = document.querySelector('#editor');
if (!editor) throw new Error("Could not find editor");
editor.style.paddingRight = `${finalCanvas.width/scale * 2}px`;
editor.style.paddingBottom = `${finalCanvas.height/scale * 2}px`;
// account for existing padding
const editorStyle = getComputedStyle(editor);
const paddingTop = isJumpGate ? 0 : parseInt(editorStyle.paddingTop, 10);
const paddingLeft = isJumpGate ? 0 : parseInt(editorStyle.paddingLeft, 10);
// scroll through and save chunks of map to output
const count = Math.ceil(width / finalCanvas.width) * Math.ceil(height / finalCanvas.height);
let progress = 0;
for (let oy = 0; oy < height; oy += finalCanvas.height) {
for (let ox = 0; ox < width; ox += finalCanvas.width) {
if (isJumpGate) {
Campaign.view.model.engine.cameraTransform.position.y = -oy + paddingTop * scale - Campaign.view.model.engine.canvas.height/2;
Campaign.view.model.engine.cameraTransform.position.x = ox + paddingLeft * scale + Campaign.view.model.engine.canvas.width/2;
} else {
editorWrapper.scrollTop = oy + paddingTop * scale;
editorWrapper.scrollLeft = ox + paddingLeft * scale;
}
// wait a frame for re-render
await raf();
const renderFrame = async (tries = 0) => {
// force re-render
window.Campaign.view.render();
// wait in increasingly long increments
for (let i = tries; i <= frameRetries; ++i) {
await raf();
}
const x = Math.floor(ox + finalCanvas.parentElement.offsetLeft * scale);
const y = Math.floor(oy + finalCanvas.parentElement.offsetTop * scale);
ctx.drawImage(finalCanvas, x, y);
// check top/bottom rows for transparent pixels to see if render failed (sometimes babylon canvas will return blank patches)
let retry = false;
const imageDataTop = ctx.getImageData(x, y, Math.min(width, finalCanvas.width), 1);
const imageDataBottom = ctx.getImageData(x, Math.min(height-1, y + finalCanvas.height - 1), Math.min(width, finalCanvas.width), 1);
for (let i = 0; i < finalCanvas.width; ++i) {
if (imageDataTop.data[i*4 + 3] === 0 || imageDataBottom.data[i*4 + 3] === 0) {
retry = true;
break;
}
}
if (retry && tries > 0) {
return renderFrame(tries-1);
} else if (retry) {
console.error(`Could not render frame after ${frameRetries} tries; continuing anyway, but please try again with "frameRetries" set to a higher number or let me know if this keeps happening!`);
}
};
await renderFrame(frameRetries);
console.log(`${Math.floor(++progress / count * 100)}%`);
}
}
// open output
var url = outputCanvas.toDataURL();
if (!url || url === 'data:,') throw new Error('Could not generate data URL. This may mean the map is too large (see notes at top of script for recommendations).');
var img = document.getElementById('roll20-map-save');
if (img) img.remove();
img = document.createElement('img');
await new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', () => {
reject(new Error('Could not render image. This may mean the map is too large (see notes at top of script for recommendations)'));
});
img.src = url;
});
if (!img.height) {
img.remove();
throw new Error('Could not render image. This may mean the map is too large (see notes at top of script for recommendations)');
}
img.id = 'roll20-map-save';
img.style.position = 'fixed';
img.style.top = '1rem';
img.style.left = '8rem';
img.style.width = '10rem';
img.style.zIndex = '10000000';
img.style.cursor = 'pointer';
img.style.border = 'solid 1px red';
img.title = 'Right-click + save/open in new tab, or left-click to delete';
img.onclick = () => {
img.remove();
};
document.body.appendChild(img);
console.log('map saved!');
} finally {
// remove extra padding
editor.style.paddingRight = null;
editor.style.paddingBottom = null;
// reset zoom
await setZoom(curZoom);
}
}
// helper
// returns promise resolving on next animation frame
function raf() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
// helper
async function setZoom(zoom) {
try {
const curZoom = parseFloat(document.querySelector('#vm_zoom_buttons .level').textContent);
if (curZoom === zoom) return;
let btns = document.querySelectorAll('.zoomDubMenuBtnStyle .el-button');
if (!btns.length) {
// open zoom popup
document.querySelector('#vm_zoom_buttons .level').click();
// give popup a couple frames to appear
await raf();
await raf();
await new Promise(r => setTimeout(r, 500));
btns = document.querySelectorAll('.zoomDubMenuBtnStyle .el-button');
}
Array.from(btns)
.map(i => [i, parseFloat(i.textContent.match(/\d+%/))])
.filter(([,i]) => !Number.isNaN(i))
.find(([,i]) => i === zoom)[0]
.click();
// give map some time to update
await raf();
await raf();
await new Promise(r => setTimeout(r, 500));
} catch (err) {
if (zoom !== 100) {
return setZoom(100);
}
}
}
// actually run it
saveMap().catch(err => {
console.error(`something went wrong while saving map
if the error mentions an "insecure operation", your map may be tainted (see notes at top of script for more info)
`, err);
});
@seleb
Copy link
Author

seleb commented Mar 12, 2025

@MercenaryDriver There was a typo causing issues with Jumpgate maps that should be fixed now. Please try the latest update and if you still have issues, let me know the specific map dimensions and I can take another look.

@MercenaryDriver
Copy link

It works now, you're the best! This has saved me a lot of hassle, thanks.

@Xristieno
Copy link

Hey there. I been getting an error where it wont capture the map. I've tried different zoom in sizes and different numbers of capture reattempts. I use waterfox for roll20 since it glitches and bugs out as a whole when actively in a game.
said error code here.

something went wrong while saving map
if the error mentions an "insecure operation", your map may be tainted (see notes at top of script for more info)
 Error: Could not render image. This may mean the map is too large (see notes at top of script for recommendations)
    saveMap debugger eval code:136
    saveMap debugger eval code:135
    saveMap debugger eval code:133
    async* debugger eval code:209
[totallyNotAnalytics.bundle.7d4b8846ddd829c919e4.js:3:52648](https://cdn.roll20.net/vtt/legacy/production/latest/totallyNotAnalytics.bundle.7d4b8846ddd829c919e4.js)
    t https://cdn.roll20.net/vtt/legacy/production/latest/totallyNotAnalytics.bundle.7d4b8846ddd829c919e4.js:3
    <anonymous> debugger eval code:210
    (Async: promise callback)
    <anonymous> debugger eval code:209
    ```

@seleb
Copy link
Author

seleb commented May 1, 2025

@Xristieno i'm unfamiliar with waterfox, but does this error happen even when you test on a small map, or if you try with a different browser? that error usually occurs when your device does not have enough memory available to safely render the combined map and the browser prevents it from allocating more

@Xristieno
Copy link

Xristieno commented May 4, 2025 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment