-
-
Save seleb/690228f38e3ef4e497760d646e6c8d8d to your computer and use it in GitHub Desktop.
/** | |
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); | |
}); |
After you run the script, a small copy of the map should appear in the upper-left corner of the screen which you can right click and save/copy/open in a new tab, or left-click to remove:
If this isn't showing up for you, lmk what browser you're using and I can see if I can reproduce the issue.
I'm using Firefox. I found it, but there's no image, it's just a red line. If I right click on it there is an image, but it's just a blank white square.
The only other browser I happen to have installed at the moment is microsoft edge, so I tried it there and it works! I tried a few other maps in both browsers and I think the problem was that the map I was unable to save was a huge map (8610x7280), but when I did it in MS Edge it saved it as a PNG with those exact dimensions.
This is awesome! Thank you!
Ah I believe you're right about it being too big: It's difficult to fix that since it's just a limitation of the browser/available memory on the hardware, but I've added some error handling so it'll be clearer when that happens. Thanks for reporting the issue!
This script is wonderful, but I think roll20 may have updated something. Seems like new and some old maps which previous exported fine, will sometimes either save with large black boxes or missing chunks, or even sometimes be completely blank. I tried testing it on different browsers and devices with the same result.
Creating a fresh 25x25 with just a few tiles placed out seems to even have the issue, though running the command multiple times sometimes gets more of the map to be captured, usually still with missing sections though.
thanks for the bug report @Kirrawk! that blank patch issue is a confusing one, but i believe the latest version fixes it (though the script may run noticeably slower as a result, especially on very large maps)
I was having the same issues as Kirrawk yesterday, but now it seems the script isn't working at all. No thumbnail image of the map appears; instead I get this
datadog-rum.js:1 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 frame after 10 tries; please try again or let me know if this keeps happening!
at renderFrame (:119:13)
at async saveMap (:122:5)
(anonymous) @ datadog-rum.js:1
z @ datadog-rum.js:1
console.error @ datadog-rum.js:1
r @ fs.js:4
(anonymous) @ VM4486:158
Promise.catch (async)
(anonymous) @ VM4486:157
@mspectre23 I haven't been able to reproduce the issue since the last update, but I uploaded a new version which may or may not work for you; please try it out and lmk if it does!
one thing you may also want to try if it fails again is changing the line const frameRetries = 10;
to a larger number. in my tests, it always worked within the first 10 retries but it's possible it just needs to wait longer in some cases (e.g. a larger map or slower pc). you could also try reducing the zoom level (though note that this will reduce the quality of the output)
That did the trick, @seleb! Thank you! (This script is a life saver, btw!!!)
The only issue seems to be with transparent backgrounds.
@mspectre23 yes, unfortunately transparent backgrounds aren't supported due to one of the updates on roll20's side (i don't have a way to differentiate pixels that rendered transparent due to errors and require a retry vs intentionally transparent pixels)
An interesting issue is that the image created includes any tokens currently on the map. Is the expected behavior?
Occurring on:
Chrome 122.0.6261.95
Microsoft Edge 122.0.2365.59 (Official build) (64-bit)
Firefox 123.0 (64-bit)
@gregorymkirk yes that's expected: functionally what this script does is scroll through the canvas taking screenshots and stitching them together to create a single full-scale image, so the output will include anything that's rendered onto it at the time
@criminal401 looks like this is a new issue, seems roll20 changed how zoom is handled. looking into it and will hopefully provide an update soon
@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!
yep, that seems to have solved my issue, I can now get the image output at 250% consistently. thank you
I think line 60 should be if (!editor) ...
not finalCanvas
@YuanHuaiYu ah yep, ty for catching that
This has saved me a whole ton of headaches for a problem I've otherwise never been able to solve.
Thank you very kindly. :)
Thank you for making this! I've been on Roll20 for 7 years and I've been dreaming of something like this for at least 6.
I'm just having one problem. I ran the code and it says "map saved", but it doesn't appear anywhere on screen. I've searched around and can't find it. What am I doing wrong? How can I find the map?
I'm really hoping I can get this to work because it will revolutionize how I make Roll20 maps. They get bogged down with too many items on the map level, so being able to build a huge map and then consolidate it into a single image is huge!
Thanks again!