Skip to content

Instantly share code, notes, and snippets.

@seleb
Last active May 18, 2024 18:56
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!
*/
// 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');
try {
console.log('saving map...');
// 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 = parseInt(editorStyle.paddingTop, 10);
const paddingLeft = 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) {
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 13, 2024

@criminal401 zoom handling should be improved now, though note that the options are a bit more limited and it still functions best when the map is already at the same zoom level as the target output. lmk if you have any more issues!

@criminal401
Copy link

yep, that seems to have solved my issue, I can now get the image output at 250% consistently. thank you

@YuanHuaiYu
Copy link

I think line 60 should be if (!editor) ... not finalCanvas

@seleb
Copy link
Author

seleb commented Mar 14, 2024

@YuanHuaiYu ah yep, ty for catching that

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