Bladeren bron

feat(generatePNGs): can generate PNGs browserless by node script, improved speed (#670)

close #670

browserless PNG generation is about twice as fast as puppeteer,
and has a way faster startup (about 4x as fast for single sample)

see the new/changed npm scripts "generatePNG*" and "generate:current".

puppeteer is not used for main scripts anymore, because it is slower,
so puppeteer was removed as devDependency, as it needs to download >100MB for Chromium.
install it as dev dependency locally if you want to test generatePNG:puppeteer*.
sschmid 5 jaren geleden
bovenliggende
commit
4089a59c55

+ 13 - 8
package.json

@@ -5,7 +5,7 @@
   "main": "build/opensheetmusicdisplay.min.js",
   "typings": "build/dist/src/",
   "scripts": {
-    "docs": "typedoc --out ./build/docs --name OpenSheetMusicDisplay --module commonjs --target ES5 --ignoreCompilerErrors --mode file ./src",
+    "docs": "typedoc --out ./build/docs --name OpenSheetMusicDisplay --module commonjs --target ES2017 --ignoreCompilerErrors --mode file ./src",
     "eslint": "eslint .",
     "tslint": "tslint --project tsconfig.json \"src/**/*.ts\" \"test/**/*.ts\"",
     "lint": "npm-run-all tslint eslint",
@@ -18,12 +18,16 @@
     "build:webpack-dev": "webpack --progress --colors --config webpack.dev.js",
     "build:webpack-sourcemap": "webpack --progress --colors --config webpack.sourcemap.js",
     "start": "webpack-dev-server --progress --colors --config webpack.dev.js",
-    "generatePNG": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data/ ./export/",
-    "generatePNG:paged": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data/ ./export 210 297 all --debug",
-    "generatePNG:paged:debug": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data/ ./export 210 297 all --debug 5000",
-    "generate:current": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data/ ./visual_regression/current",
-    "generate:current:singletest": "node test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./visual_regression/current .*function_test_all.*",
-    "generate:blessed": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data/ ./visual_regression/blessed",
+    "generatePNG": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export",
+    "generatePNG:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export 0 0 ^Beethoven",
+    "generatePNG:puppeteer": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export 0 0 all",
+    "generatePNG:puppeteer:single": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export 0 0 ^Beethoven",
+    "generatePNG:paged": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export 210 297 all",
+    "generatePNG:paged:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export 210 297 all --debug 5000",
+    "generatePNG:paged:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export 0 0 ^Beethoven",
+    "generate:current": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/current 0 0 all --debug",
+    "generate:current:singletest": "node test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/current 0 0 .*function_test_all.*",
+    "generate:blessed": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./visual_regression/blessed",
     "test:visual": "sh ./test/Util/visual_regression.sh ./visual_regression",
     "test:visual:singletest": "sh ./test/Util/visual_regression.sh ./visual_regression OSMD_function_test_all",
     "fix-memory-limit": "cross-env NODE_OPTIONS=--max_old_space_size=4096"
@@ -69,6 +73,7 @@
     "@types/chai": "^4.2.9",
     "@types/mocha": "^7.0.1",
     "@types/node": "^13.7.4",
+    "canvas": "^2.6.1",
     "chai": "^4.1.0",
     "clean-webpack-plugin": "^1.0.1",
     "cross-blob": "^1.2.0",
@@ -82,6 +87,7 @@
     "eslint-plugin-standard": "^4.0.0",
     "html-webpack-plugin": "^3.2.0",
     "jquery": "^3.4.1",
+    "jsdom": "^16.2.0",
     "karma": "^4.1.0",
     "karma-base64-to-js-preprocessor": "^0.0.1",
     "karma-chai": "^0.1.0",
@@ -94,7 +100,6 @@
     "mocha": "^7.0.1",
     "npm-run-all": "^4.1.2",
     "pre-commit": "^1.2.2",
-    "puppeteer": "^2.1.1",
     "ts-loader": "^4.1.0",
     "tslint": "^5.14.0",
     "tslint-loader": "^3.5.4",

+ 6 - 2
src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts

@@ -188,11 +188,13 @@ export class OpenSheetMusicDisplay {
 
         // Set page width
         const width: number = this.container.offsetWidth;
-        //console.log("[OSMD] container width: " + width);
+        // log.debug("[OSMD] container width: " + width);
         this.sheet.pageWidth = width / this.zoom / 10.0;
         if (EngravingRules.Rules.PageFormat && !EngravingRules.Rules.PageFormat.IsUndefined) {
             EngravingRules.Rules.PageHeight = this.sheet.pageWidth / EngravingRules.Rules.PageFormat.aspectRatio;
+            log.debug("[OSMD] PageHeight: " + EngravingRules.Rules.PageHeight);
         } else {
+            log.debug("[OSMD] endless/undefined pageformat, id: " + EngravingRules.Rules.PageFormat.idString);
             EngravingRules.Rules.PageHeight = 100001; // infinite page height // TODO maybe Number.MAX_VALUE or Math.pow(10, 20)?
         }
 
@@ -269,8 +271,10 @@ export class OpenSheetMusicDisplay {
             }
             if (EngravingRules.Rules.PageFormat && !EngravingRules.Rules.PageFormat.IsUndefined) {
                 height = width / EngravingRules.Rules.PageFormat.aspectRatio;
+                // console.log("pageformat given. height: " + page.PositionAndShape.Size.height);
             } else {
                 height = (page.PositionAndShape.Size.height + 15) * this.zoom * 10.0;
+                // console.log("pageformat not given. height: " + page.PositionAndShape.Size.height);
             }
             if (backend.getOSMDBackendType() === BackendType.Canvas && height > canvasDimensionsLimit) {
                 console.log("[OSMD] Warning: height of " + height + sizeWarningPartTwo);
@@ -678,7 +682,7 @@ export class OpenSheetMusicDisplay {
             const width: number = Number.parseInt(widthAndHeight[0], 10);
             const height: number = Number.parseInt(widthAndHeight[1], 10);
             if (width > 0 && width < 32768 && height > 0 && height < 32768) {
-                pageFormat = new PageFormat(width, height, "customPageFormatWidthHeight");
+                pageFormat = new PageFormat(width, height, `customPageFormat${pageFormatString}`);
             }
         }
 

+ 30 - 8
test/Util/generateDiffImagesPuppeteerLocalhost.js

@@ -6,6 +6,9 @@
 
   This is meant to be used with the visual regression test system in
   `tools/visual_regression.sh`. (TODO)
+
+  You may have to install puppeteer as dev dependency to run this:
+  npm i puppeteer --save-dev
 */
 
 function sleep (ms) {
@@ -54,6 +57,9 @@ async function init () {
     }
 
     const fs = require('fs')
+    // Create the image directory if it doesn't exist.
+    fs.mkdirSync(imageDir, { recursive: true })
+
     const sampleDirFilenames = fs.readdirSync(sampleDir)
     let samplesToProcess = [] // samples we want to process/generate pngs of, excluding the filtered out files/filenames
     for (const sampleFilename of sampleDirFilenames) {
@@ -65,17 +71,14 @@ async function init () {
         }
         // eslint-disable-next-line no-useless-escape
         if (sampleFilename.match('^.*(\.xml)|(\.musicxml)|(\.mxl)$')) {
-            console.log('found musicxml/mxl: ' + sampleFilename)
+            // console.log('found musicxml/mxl: ' + sampleFilename)
             samplesToProcess.push(sampleFilename)
         } else {
             console.log('discarded file/directory: ' + sampleFilename)
         }
     }
 
-    // Create the image directory if it doesn't exist.
-    fs.mkdirSync(imageDir, { recursive: true })
-
-    // filter regex if given
+    // filter samples to process by regex if given
     if (filterRegex && filterRegex !== '' && filterRegex !== 'all') {
         console.log('filtering samples for regex: ' + filterRegex)
         samplesToProcess = samplesToProcess.filter((filename) => filename.match(filterRegex))
@@ -174,7 +177,7 @@ async function init () {
         for (let urlIndex = 0; urlIndex < dataUrls.length; urlIndex++) {
             const pageNumberingString = `_${urlIndex + 1}`
             // pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
-            var filename = `${imageDir}/${sampleFilename}${pageNumberingString}.png`
+            var pageFilename = `${imageDir}/${sampleFilename}${pageNumberingString}.png`
 
             const dataUrl = dataUrls[urlIndex]
             if (!dataUrl || !dataUrl.split) {
@@ -184,9 +187,28 @@ async function init () {
             const imageData = dataUrl.split(';base64,').pop()
             const imageBuffer = Buffer.from(imageData, 'base64')
 
-            console.log('got image data, saving to: ' + filename)
-            fs.writeFileSync(filename, imageBuffer, { encoding: 'base64' })
+            console.log('got image data, saving to: ' + pageFilename)
+            fs.writeFileSync(pageFilename, imageBuffer, { encoding: 'base64' })
         }
+        /* bneumann's SVG method */
+        // const clone = this.ctx.svg.cloneNode(true) // SVGElement
+        // // create a doctype that is SVG
+        // const svgDocType = document.implementation.createDocumentType(
+        //     'svg',
+        //     '-//W3C//DTD SVG 1.1//EN',
+        //     'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'
+        // )
+        // // Create a new svg document
+        // const svgDoc = document.implementation.createDocument('http://www.w3.org/2000/svg', 'svg', svgDocType)
+        // // replace the documentElement with our clone
+        // svgDoc.replaceChild(clone, svgDoc.documentElement)
+        // // get the data
+        // const svgData = (new XMLSerializer()).serializeToString(svgDoc)
+        // var blob = new Blob([svgData.replace(/></g, '>\n\r<')])
+        // fs.writeFileSync(filename, blob)
+        // //fs.writeFileSync(filename, svgData)
+        // return
+        /* end bneumann's svg method */
     }
 
     // const html = await page.content();

+ 253 - 0
test/Util/generateImages_browserless.js

@@ -0,0 +1,253 @@
+/*
+  Render each OSMD sample, grab the generated images, and
+  dump them into a local directory as PNG files.
+
+  inspired by Vexflow's generate_png_images and vexflow-tests.js
+
+  This can be used to generate PNGs from OSMD without a browser.
+  It's also used with the visual regression test system in
+  `tools/visual_regression.sh`.
+
+  Note: this script needs to "fake" quite a few browser elements, like window, document, and a Canvas HTMLElement.
+  For that it needs the canvas package installed.
+  There are also some hacks needed to set the container size (offsetWidth) correctly.
+*/
+
+function sleep (ms) {
+    return new Promise((resolve) => {
+        setTimeout(resolve, ms)
+    })
+}
+
+async function init () {
+    console.log('[OSMD.generate] init')
+
+    let [osmdBuildDir, sampleDir, imageDir, pageWidth, pageHeight, filterRegex, debugFlag, debugSleepTimeString] = process.argv.slice(2, 10)
+    if (!osmdBuildDir || !sampleDir || !imageDir) {
+        console.log('usage: node test/Util/generateImages_browserless.js osmdBuildDir sampleDirectory imageDirectory [width|0] [height|0] [filterRegex|all] [--debug] [debugSleepTime]')
+        console.log('  (use "all" to skip filterRegex parameter)')
+        console.log('example: node test/Util/generateImages_browserless.js ../../build ./test/data/ ./export 210 297 all --debug 5000')
+        console.log('Error: need sampleDir and imageDir. Exiting.')
+        process.exit(1)
+    }
+    console.log('sampleDir: ' + sampleDir)
+    console.log('imageDir: ' + imageDir)
+
+    let pageFormat = 'Endless'
+    pageWidth = Number.parseInt(pageWidth)
+    pageHeight = Number.parseInt(pageHeight)
+    const endlessPage = !(pageHeight > 0 && pageWidth > 0)
+    if (!endlessPage) {
+        pageFormat = `${pageWidth}x${pageHeight}`
+    }
+
+    const DEBUG = debugFlag === '--debug'
+    // const debugSleepTime = Number.parseInt(process.env.GENERATE_DEBUG_SLEEP_TIME) || 0; // 5000 works for me [sschmidTU]
+    if (DEBUG) {
+        console.log('debug sleep time: ' + debugSleepTimeString)
+        const debugSleepTimeMs = Number.parseInt(debugSleepTimeString)
+        if (debugSleepTimeMs > 0) {
+            await sleep(Number.parseInt(debugSleepTimeMs))
+            // [VSCode] apparently this is necessary for the debugger to attach itself in time before the program closes.
+            // sometimes this is not enough, so you may have to try multiple times or increase the sleep timer. Unfortunately debugging nodejs isn't easy.
+        }
+    }
+
+    // ---- hacks to fake Browser elements OSMD and Vexflow need, like window, document, and a canvas HTMLElement ----
+    const { JSDOM } = require('jsdom')
+    const dom = new JSDOM('<!DOCTYPE html></html>')
+    // eslint-disable-next-line no-global-assign
+    window = dom.window
+    // eslint-disable-next-line no-global-assign
+    document = dom.window.document
+
+    // eslint-disable-next-line no-global-assign
+    global.window = dom.window
+    // eslint-disable-next-line no-global-assign
+    global.document = window.document
+    global.HTMLElement = window.HTMLElement
+    global.HTMLAnchorElement = window.HTMLAnchorElement
+    global.XMLHttpRequest = window.XMLHttpRequest
+    global.DOMParser = window.DOMParser
+    global.Node = window.Node
+    global.Canvas = window.Canvas
+
+    // fix Blob not found
+    const Blob = require('cross-blob')
+
+    // eslint-disable-next-line no-new
+    new Blob([])
+    // => Blob {size: 0, type: ''}
+
+    // Global patch (to support external modules like is-blob).
+    global.Blob = Blob
+
+    const div = document.createElement('div')
+    div.id = 'browserlessDiv'
+    document.body.appendChild(div)
+    // const canvas = document.createElement('canvas')
+    // div.canvas = document.createElement('canvas')
+
+    const zoom = 1.0
+    // somehow, witdh * 5 will preserve the aspect ratio (0.7070 repeating, *1 will be way too short, *10 too long)
+    // there's width * zoom * 10 in the OSMD code because Vexflow's pixels are OSMD's size units * 10, so i thought it should be * 10.
+    // not sure where the / 2 factor comes from.
+    let width = pageWidth * zoom * 5
+    if (endlessPage) {
+        width = 1440
+    }
+    let height = pageHeight
+    if (endlessPage) {
+        height = 32767
+    }
+    div.width = width
+    div.height = height
+    div.offsetWidth = width // doesn't work, offsetWidth is always 0 from this. see below
+    div.clientWidth = width
+    div.clientHeight = height
+    div.scrollHeight = height
+    div.scrollWidth = width
+    div.setAttribute('width', width)
+    div.setAttribute('height', height)
+    div.setAttribute('offsetWidth', width)
+    debug('div.offsetWidth: ' + div.offsetWidth, DEBUG)
+    debug('div.height: ' + div.height, DEBUG)
+
+    // hack: set offsetWidth reliably
+    Object.defineProperties(window.HTMLElement.prototype, {
+        offsetLeft: {
+            get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0 }
+        },
+        offsetTop: {
+            get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0 }
+        },
+        offsetHeight: {
+            get: function () { return height }
+        },
+        offsetWidth: {
+            get: function () { return width }
+        }
+    })
+    debug('div.offsetWidth: ' + div.offsetWidth, DEBUG)
+    debug('div.height: ' + div.height, DEBUG)
+    // ---- end browser hacks (hopefully) ----
+
+    const OSMD = require(`${osmdBuildDir}/opensheetmusicdisplay.min.js`)
+
+    const fs = require('fs')
+    // Create the image directory if it doesn't exist.
+    fs.mkdirSync(imageDir, { recursive: true })
+
+    const sampleDirFilenames = fs.readdirSync(sampleDir)
+    let samplesToProcess = [] // samples we want to process/generate pngs of, excluding the filtered out files/filenames
+    for (const sampleFilename of sampleDirFilenames) {
+        if (DEBUG) {
+            if (sampleFilename.match('^(Actor)|(Gounod)')) {
+                console.log('DEBUG: filtering big file: ' + sampleFilename)
+                continue
+            }
+        }
+        // eslint-disable-next-line no-useless-escape
+        if (sampleFilename.match('^.*(\.xml)|(\.musicxml)|(\.mxl)$')) {
+            // console.log('found musicxml/mxl: ' + sampleFilename)
+            samplesToProcess.push(sampleFilename)
+        } else {
+            console.log('discarded file/directory: ' + sampleFilename)
+        }
+    }
+
+    // filter samples to process by regex if given
+    if (filterRegex && filterRegex !== '' && filterRegex !== 'all') {
+        console.log('filtering samples for regex: ' + filterRegex)
+        samplesToProcess = samplesToProcess.filter((filename) => filename.match(filterRegex))
+        console.log(`found ${samplesToProcess.length} matches: `)
+        for (let i = 0; i < samplesToProcess.length; i++) {
+            console.log(samplesToProcess[i])
+        }
+    }
+
+    const osmdInstance = new OSMD.OpenSheetMusicDisplay(div, {
+        autoResize: false,
+        backend: 'canvas',
+        pageBackgroundColor: '#FFFFFF',
+        pageFormat: pageFormat
+    })
+    // await sleep(5000)
+    if (DEBUG) {
+        osmdInstance.setLogLevel('debug')
+        // console.log(`osmd PageFormat: ${osmdInstance.EngravingRules.PageFormat.width}x${osmdInstance.EngravingRules.PageFormat.height}`)
+        console.log(`osmd PageFormat idString: ${osmdInstance.EngravingRules.PageFormat.idString}`)
+        console.log('PageHeight: ' + osmdInstance.EngravingRules.PageHeight)
+    }
+
+    debug('generateImages', DEBUG)
+    for (let i = 0; i < samplesToProcess.length; i++) {
+        var sampleFilename = samplesToProcess[i]
+        debug('sampleFilename: ' + sampleFilename, DEBUG)
+
+        let loadParameter = fs.readFileSync(sampleDir + '/' + sampleFilename)
+        if (sampleFilename.endsWith('.mxl')) {
+            loadParameter = await OSMD.MXLHelper.MXLtoXMLstring(loadParameter)
+        } else {
+            loadParameter = loadParameter.toString()
+        }
+        // console.log('loadParameter: ' + loadParameter)
+        // console.log('typeof loadParameter: ' + typeof loadParameter)
+
+        await osmdInstance.load(loadParameter).then(function () {
+            debug('xml loaded', DEBUG)
+            try {
+                osmdInstance.render()
+            } catch (ex) {
+                console.log('renderError: ' + ex)
+            }
+            debug('rendered', DEBUG)
+
+            const dataUrls = []
+            let canvasImage
+
+            for (let pageNumber = 1; pageNumber < 999; pageNumber++) {
+                canvasImage = document.getElementById('osmdCanvasVexFlowBackendCanvas' + pageNumber)
+                if (!canvasImage) {
+                    break
+                }
+                if (!canvasImage.toDataURL) {
+                    console.log(`error: could not get canvas image for page ${pageNumber} for file: ${sampleFilename}`)
+                    break
+                }
+                dataUrls.push(canvasImage.toDataURL())
+            }
+            for (let urlIndex = 0; urlIndex < dataUrls.length; urlIndex++) {
+                const pageNumberingString = `_${urlIndex + 1}`
+                // pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
+                var pageFilename = `${imageDir}/${sampleFilename}${pageNumberingString}.png`
+
+                const dataUrl = dataUrls[urlIndex]
+                if (!dataUrl || !dataUrl.split) {
+                    console.log(`error: could not get dataUrl (imageData) for page ${urlIndex + 1} of sample: ${sampleFilename}`)
+                    continue
+                }
+                const imageData = dataUrl.split(';base64,').pop()
+                const imageBuffer = Buffer.from(imageData, 'base64')
+
+                console.log('got image data, saving to: ' + pageFilename)
+                fs.writeFileSync(pageFilename, imageBuffer, { encoding: 'base64' })
+            }
+        }) // end render then
+        //     },
+        //     function (e) {
+        //         console.log('error while rendering: ' + e)
+        //     }) // end load then
+        // }) // end read file
+    }
+
+    console.log('[OSMD.generate_browserless] exit')
+}
+
+function debug (msg, debugEnabled) {
+    if (debugEnabled) {
+        console.log(msg)
+    }
+}
+
+init()