generateImages_browserless.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /*
  2. Render each OSMD sample, grab the generated images, and
  3. dump them into a local directory as PNG files.
  4. inspired by Vexflow's generate_png_images and vexflow-tests.js
  5. This can be used to generate PNGs from OSMD without a browser.
  6. It's also used with the visual regression test system in
  7. `tools/visual_regression.sh`
  8. (see package.json, used with npm run generate:blessed and generate:current, then test:visual).
  9. Note: this script needs to "fake" quite a few browser elements, like window, document, and a Canvas HTMLElement,
  10. which otherwise are missing in pure nodejs, causing errors in OSMD.
  11. For that it needs the canvas package installed.
  12. There are also some hacks needed to set the container size (offsetWidth) correctly.
  13. Otherwise you'd need to run a headless browser, which is way slower,
  14. see the semi-obsolete generateDiffImagesPuppeteerLocalhost.js
  15. */
  16. function sleep (ms) {
  17. return new Promise((resolve) => {
  18. setTimeout(resolve, ms)
  19. })
  20. }
  21. // global variables
  22. // (without these being global, we'd have to pass many of these values to the generateSampleImage function)
  23. let [osmdBuildDir, sampleDir, imageDir, pageWidth, pageHeight, filterRegex, mode, debugSleepTimeString] = process.argv.slice(2, 10)
  24. if (!osmdBuildDir || !sampleDir || !imageDir) {
  25. console.log('usage: ' +
  26. 'node test/Util/generateImages_browserless.js osmdBuildDir sampleDirectory imageDirectory [width|0] [height|0] [filterRegex|all|allSmall] [--debug|--osmdtesting] [debugSleepTime]')
  27. console.log(' (use "all" to skip filterRegex parameter. "allSmall" with --osmdtesting skips two huge OSMD samples that take forever to render)')
  28. console.log('example: node test/Util/generateImages_browserless.js ../../build ./test/data/ ./export 210 297 allSmall --debug 5000')
  29. console.log('Error: need osmdBuildDir, sampleDir and imageDir. Exiting.')
  30. process.exit(1)
  31. }
  32. let pageFormat
  33. if (!mode) {
  34. mode = ''
  35. }
  36. let OSMD // can only be required once window was simulated
  37. const FS = require('fs')
  38. async function init () {
  39. console.log('[OSMD.generateImages] init')
  40. const osmdTestingMode = mode.includes('osmdtesting') // can also be --debugosmdtesting
  41. const osmdTestingSingleMode = mode.includes('osmdtestingsingle')
  42. const DEBUG = mode.startsWith('--debug')
  43. // const debugSleepTime = Number.parseInt(process.env.GENERATE_DEBUG_SLEEP_TIME) || 0; // 5000 works for me [sschmidTU]
  44. if (DEBUG) {
  45. // console.log(' (note that --debug slows down the script by about 0.3s per file, through logging)')
  46. const debugSleepTimeMs = Number.parseInt(debugSleepTimeString)
  47. if (debugSleepTimeMs > 0) {
  48. console.log('debug sleep time: ' + debugSleepTimeString)
  49. await sleep(Number.parseInt(debugSleepTimeMs))
  50. // [VSCode] apparently this is necessary for the debugger to attach itself in time before the program closes.
  51. // sometimes this is not enough, so you may have to try multiple times or increase the sleep timer. Unfortunately debugging nodejs isn't easy.
  52. }
  53. }
  54. debug('sampleDir: ' + sampleDir, DEBUG)
  55. debug('imageDir: ' + imageDir, DEBUG)
  56. pageFormat = 'Endless'
  57. pageWidth = Number.parseInt(pageWidth)
  58. pageHeight = Number.parseInt(pageHeight)
  59. const endlessPage = !(pageHeight > 0 && pageWidth > 0)
  60. if (!endlessPage) {
  61. pageFormat = `${pageWidth}x${pageHeight}`
  62. }
  63. // ---- hacks to fake Browser elements OSMD and Vexflow need, like window, document, and a canvas HTMLElement ----
  64. var jsdom = require('jsdom')
  65. const dom = new jsdom.JSDOM('<!DOCTYPE html></html>')
  66. // eslint-disable-next-line no-global-assign
  67. window = dom.window
  68. // eslint-disable-next-line no-global-assign
  69. document = dom.window.document
  70. // eslint-disable-next-line no-global-assign
  71. global.window = dom.window
  72. // eslint-disable-next-line no-global-assign
  73. global.document = window.document
  74. window.console = console // probably does nothing
  75. global.HTMLElement = window.HTMLElement
  76. global.HTMLAnchorElement = window.HTMLAnchorElement
  77. global.XMLHttpRequest = window.XMLHttpRequest
  78. global.DOMParser = window.DOMParser
  79. global.Node = window.Node
  80. global.Canvas = window.Canvas
  81. // fix Blob not found (to support external modules like is-blob)
  82. global.Blob = require('cross-blob')
  83. const div = document.createElement('div')
  84. div.id = 'browserlessDiv'
  85. document.body.appendChild(div)
  86. // const canvas = document.createElement('canvas')
  87. // div.canvas = document.createElement('canvas')
  88. const zoom = 1.0
  89. // width of the div / PNG generated
  90. let width = pageWidth * zoom
  91. // TODO sometimes the width is way too small for the score, may need to adjust zoom.
  92. if (endlessPage) {
  93. width = 1440
  94. }
  95. let height = pageHeight
  96. if (endlessPage) {
  97. height = 32767
  98. }
  99. div.width = width
  100. div.height = height
  101. div.offsetWidth = width // doesn't work, offsetWidth is always 0 from this. see below
  102. div.clientWidth = width
  103. div.clientHeight = height
  104. div.scrollHeight = height
  105. div.scrollWidth = width
  106. div.setAttribute('width', width)
  107. div.setAttribute('height', height)
  108. div.setAttribute('offsetWidth', width)
  109. // debug('div.offsetWidth: ' + div.offsetWidth, DEBUG) // 0 here, set correctly later
  110. // debug('div.height: ' + div.height, DEBUG)
  111. // hack: set offsetWidth reliably
  112. Object.defineProperties(window.HTMLElement.prototype, {
  113. offsetLeft: {
  114. get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0 }
  115. },
  116. offsetTop: {
  117. get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0 }
  118. },
  119. offsetHeight: {
  120. get: function () { return height }
  121. },
  122. offsetWidth: {
  123. get: function () { return width }
  124. }
  125. })
  126. debug('div.offsetWidth: ' + div.offsetWidth, DEBUG)
  127. debug('div.height: ' + div.height, DEBUG)
  128. // ---- end browser hacks (hopefully) ----
  129. // load globally
  130. OSMD = require(`${osmdBuildDir}/opensheetmusicdisplay.min.js`) // window needs to be available before we can require OSMD
  131. // Create the image directory if it doesn't exist.
  132. FS.mkdirSync(imageDir, { recursive: true })
  133. const sampleDirFilenames = FS.readdirSync(sampleDir)
  134. let samplesToProcess = [] // samples we want to process/generate pngs of, excluding the filtered out files/filenames
  135. for (const sampleFilename of sampleDirFilenames) {
  136. if (osmdTestingMode && filterRegex === 'allSmall') {
  137. if (sampleFilename.match('^(Actor)|(Gounod)')) { // TODO maybe filter by file size instead
  138. debug('filtering big file: ' + sampleFilename, DEBUG)
  139. continue
  140. }
  141. }
  142. // eslint-disable-next-line no-useless-escape
  143. if (sampleFilename.match('^.*(\.xml)|(\.musicxml)|(\.mxl)$')) {
  144. // console.log('found musicxml/mxl: ' + sampleFilename)
  145. samplesToProcess.push(sampleFilename)
  146. } else {
  147. debug('discarded file/directory: ' + sampleFilename, DEBUG)
  148. }
  149. }
  150. // filter samples to process by regex if given
  151. if (filterRegex && filterRegex !== '' && filterRegex !== 'all' && !(osmdTestingMode && filterRegex === 'allSmall')) {
  152. debug('filtering samples for regex: ' + filterRegex, DEBUG)
  153. samplesToProcess = samplesToProcess.filter((filename) => filename.match(filterRegex))
  154. debug(`found ${samplesToProcess.length} matches: `, DEBUG)
  155. for (let i = 0; i < samplesToProcess.length; i++) {
  156. debug(samplesToProcess[i], DEBUG)
  157. }
  158. }
  159. const osmdInstance = new OSMD.OpenSheetMusicDisplay(div, {
  160. autoResize: false,
  161. backend: 'canvas',
  162. pageBackgroundColor: '#FFFFFF',
  163. pageFormat: pageFormat
  164. // defaultFontFamily: 'Arial',
  165. // drawTitle: false
  166. })
  167. // for more options check OSMDOptions.ts
  168. // you can set finer-grained rendering/engraving settings in EngravingRules:
  169. // osmdInstance.EngravingRules.TitleTopDistance = 5.0 // 5.0 is default
  170. // (unless in osmdTestingMode, these will be reset with drawingParameters default)
  171. // osmdInstance.EngravingRules.PageTopMargin = 5.0 // 5 is default
  172. // osmdInstance.EngravingRules.PageBottomMargin = 5.0 // 5 is default. <5 can cut off scores that extend in the last staffline
  173. // note that for now the png and canvas will still have the height given in the script argument,
  174. // so even with a margin of 0 the image will be filled to the full height.
  175. // osmdInstance.EngravingRules.PageLeftMargin = 5.0 // 5 is default
  176. // osmdInstance.EngravingRules.PageRightMargin = 5.0 // 5 is default
  177. // osmdInstance.EngravingRules.MetronomeMarkXShift = -8; // -6 is default
  178. // osmdInstance.EngravingRules.DistanceBetweenVerticalSystemLines = 0.15; // 0.35 is default
  179. // for more options check EngravingRules.ts (though not all of these are meant and fully supported to be changed at will)
  180. if (DEBUG) {
  181. osmdInstance.setLogLevel('debug')
  182. // console.log(`osmd PageFormat: ${osmdInstance.EngravingRules.PageFormat.width}x${osmdInstance.EngravingRules.PageFormat.height}`)
  183. console.log(`osmd PageFormat idString: ${osmdInstance.EngravingRules.PageFormat.idString}`)
  184. console.log('PageHeight: ' + osmdInstance.EngravingRules.PageHeight)
  185. } else {
  186. osmdInstance.setLogLevel('info') // doesn't seem to work, log.debug still logs
  187. }
  188. debug('[OSMD.generateImages] starting loop over samples, saving images to ' + imageDir, DEBUG)
  189. for (let i = 0; i < samplesToProcess.length; i++) {
  190. var sampleFilename = samplesToProcess[i]
  191. debug('sampleFilename: ' + sampleFilename, DEBUG)
  192. await generateSampleImage(sampleFilename, sampleDir, osmdInstance, osmdTestingMode, false)
  193. if (osmdTestingMode && !osmdTestingSingleMode && sampleFilename.startsWith('Beethoven') && sampleFilename.includes('Geliebte')) {
  194. // generate one more testing image with skyline and bottomline. (startsWith 'Beethoven' don't catch the function test)
  195. await generateSampleImage(sampleFilename, sampleDir, osmdInstance, osmdTestingMode, true, DEBUG)
  196. }
  197. }
  198. console.log('[OSMD.generateImages] done, exiting.')
  199. }
  200. // eslint-disable-next-line
  201. // let maxRss = 0, maxRssFilename = '' // to log memory usage (debug)
  202. async function generateSampleImage (sampleFilename, directory, osmdInstance, osmdTestingMode,
  203. includeSkyBottomLine = false, DEBUG = false) {
  204. var samplePath = directory + '/' + sampleFilename
  205. let loadParameter = FS.readFileSync(samplePath)
  206. if (sampleFilename.endsWith('.mxl')) {
  207. loadParameter = await OSMD.MXLHelper.MXLtoXMLstring(loadParameter)
  208. } else {
  209. loadParameter = loadParameter.toString()
  210. }
  211. // console.log('loadParameter: ' + loadParameter)
  212. // console.log('typeof loadParameter: ' + typeof loadParameter)
  213. // set sample-specific options for OSMD visual regression testing
  214. if (osmdTestingMode) {
  215. const isFunctionTestAutobeam = sampleFilename.startsWith('OSMD_function_test_autobeam')
  216. const isFunctionTestAutoColoring = sampleFilename.startsWith('OSMD_function_test_auto-custom-coloring')
  217. const isFunctionTestSystemAndPageBreaks = sampleFilename.startsWith('OSMD_Function_Test_System_and_Page_Breaks')
  218. const isFunctionTestDrawingRange = sampleFilename.startsWith('OSMD_function_test_measuresToDraw_')
  219. const defaultOrCompactTightMode = sampleFilename.startsWith('OSMD_Function_Test_Container_height') ? 'compacttight' : 'default'
  220. osmdInstance.setOptions({
  221. autoBeam: isFunctionTestAutobeam, // only set to true for function test autobeam
  222. coloringMode: isFunctionTestAutoColoring ? 2 : 0,
  223. coloringSetCustom: isFunctionTestAutoColoring ? ['#d82c6b', '#F89D15', '#FFE21A', '#4dbd5c', '#009D96', '#43469d', '#76429c', '#ff0000'] : undefined,
  224. colorStemsLikeNoteheads: isFunctionTestAutoColoring,
  225. drawingParameters: defaultOrCompactTightMode, // note: default resets all EngravingRules. could be solved differently
  226. drawFromMeasureNumber: isFunctionTestDrawingRange ? 9 : 1,
  227. drawUpToMeasureNumber: isFunctionTestDrawingRange ? 12 : Number.MAX_SAFE_INTEGER,
  228. newSystemFromXML: isFunctionTestSystemAndPageBreaks,
  229. newPageFromXML: isFunctionTestSystemAndPageBreaks,
  230. pageBackgroundColor: '#FFFFFF', // reset by drawingparameters default
  231. pageFormat: pageFormat // reset by drawingparameters default
  232. })
  233. osmdInstance.drawSkyLine = includeSkyBottomLine // if includeSkyBottomLine, draw skyline and bottomline, else not
  234. osmdInstance.drawBottomLine = includeSkyBottomLine
  235. }
  236. await osmdInstance.load(loadParameter) // if using load.then() without await, memory will not be freed up between renders
  237. debug('xml loaded', DEBUG)
  238. try {
  239. osmdInstance.render()
  240. } catch (ex) {
  241. console.log('renderError: ' + ex)
  242. }
  243. debug('rendered', DEBUG)
  244. const dataUrls = []
  245. let canvasImage
  246. for (let pageNumber = 1; pageNumber < 999; pageNumber++) {
  247. canvasImage = document.getElementById('osmdCanvasVexFlowBackendCanvas' + pageNumber)
  248. if (!canvasImage) {
  249. break
  250. }
  251. if (!canvasImage.toDataURL) {
  252. console.log(`error: could not get canvas image for page ${pageNumber} for file: ${sampleFilename}`)
  253. break
  254. }
  255. dataUrls.push(canvasImage.toDataURL())
  256. }
  257. for (let urlIndex = 0; urlIndex < dataUrls.length; urlIndex++) {
  258. const pageNumberingString = `${urlIndex + 1}`
  259. const skybottomlineString = includeSkyBottomLine ? 'skybottomline_' : ''
  260. // pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
  261. var pageFilename = `${imageDir}/${sampleFilename}_${skybottomlineString}${pageNumberingString}.png`
  262. const dataUrl = dataUrls[urlIndex]
  263. if (!dataUrl || !dataUrl.split) {
  264. console.log(`error: could not get dataUrl (imageData) for page ${urlIndex + 1} of sample: ${sampleFilename}`)
  265. continue
  266. }
  267. const imageData = dataUrl.split(';base64,').pop()
  268. const imageBuffer = Buffer.from(imageData, 'base64')
  269. debug('got image data, saving to: ' + pageFilename, DEBUG)
  270. FS.writeFileSync(pageFilename, imageBuffer, { encoding: 'base64' })
  271. // debug: log memory usage
  272. // const usage = process.memoryUsage()
  273. // for (const entry of Object.entries(usage)) {
  274. // if (entry[0] === 'rss') {
  275. // if (entry[1] > maxRss) {
  276. // maxRss = entry[1]
  277. // maxRssFilename = pageFilename
  278. // }
  279. // }
  280. // console.log(entry[0] + ': ' + entry[1] / (1024 * 1024) + 'mb')
  281. // }
  282. // console.log('maxRss: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
  283. }
  284. // console.log('maxRss total: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
  285. // await sleep(5000)
  286. // }) // end read file
  287. }
  288. function debug (msg, debugEnabled) {
  289. if (debugEnabled) {
  290. console.log(msg)
  291. }
  292. }
  293. init()