Last active
October 29, 2024 15:21
-
-
Save seleb/690228f38e3ef4e497760d646e6c8d8d to your computer and use it in GitHub Desktop.
script for exporting roll20 maps to an image
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
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); | |
}); |
@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. :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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