-
-
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! 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); | |
}); |
@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.
It works now, you're the best! This has saved me a lot of hassle, thanks.
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
```
@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
I'm having some issues with part of the map being clipped off. I'll send a few examples ran at zoom = 100, frame retries = 10 with the missing portion circled:






Increasing frame retries doesn't seem to help. Let me know if you would like me to do some more testing with these maps as this issue seems to affect most of my maps made in Jumpgate (but barely any maps made in non-Jumpgate campaigns, even those of comparable or larger size and quality).