Browse Source

feat(Performance): Add 'performance mode': calc skybottom lines with browser SVG

uses browser SVG DOM to calculate skyline/bottomline (bounds of the stafflines) for positioning.

About 2-3x faster than the old method of getting Vexflow's rendered image.

This is problematic with visual regression tests though, because the browser isn't available there,
and you'd need a polyfill for some SVG elements. So there, puppeteer would need to be used
to regression test this skyline calculation method.
sschmid 3 years ago
parent
commit
a78efede99

+ 7 - 0
demo/index.html

@@ -153,6 +153,13 @@
             <button class="ui button" id="transpose-btn">Transpose</button>
         </div>
     </div>
+    <div class="column">
+        <h3 class="ui header">Performance Mode:</h3>
+        <div class="ui toggle checkbox">
+            <input type="checkbox" name="public" id="performanceMode">
+            <label>Performance Mode</label>
+        </div>
+    </div>
 </div>
 <div id="optionalControls" style="opacity: 0.0; width: 95%; display: block">
     <div class="ui three column grid container" style="padding: 10px; margin-right: auto; margin-left: auto" id="optionalControlsColumnContainer">

+ 13 - 1
demo/index.js

@@ -102,6 +102,8 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
         printPdfBtns,
         transpose,
         transposeBtn,
+        performanceMode,
+        performanceModeBtn,
         playbackControlsButton,
         playbackControl;
     
@@ -226,6 +228,8 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
         selectBounding = document.getElementById("selectBounding");
         skylineDebug = document.getElementById("skylineDebug");
         bottomlineDebug = document.getElementById("bottomlineDebug");
+        performanceMode = false;
+        performanceModeBtn = document.getElementById("performanceMode");
         zoomIns = [];
         zoomIns.push(document.getElementById("zoom-in-btn"));
         zoomIns.push(document.getElementById("zoom-in-btn-optional"));
@@ -425,6 +429,13 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
             }
         }
 
+        if (performanceModeBtn) {
+            performanceModeBtn.onclick = function () {
+                performanceMode = !performanceMode;
+                openSheetMusicDisplay.setOptions({performanceMode});
+            }
+        }
+
         if (debugReRenderBtn) {
             debugReRenderBtn.onclick = function () {
                 rerender();
@@ -473,7 +484,8 @@ import { TransposeCalculator } from '../src/Plugins/Transpose/TransposeCalculato
             },
             pageFormat: pageFormat,
             pageBackgroundColor: pageBackgroundColor,
-            renderSingleHorizontalStaffline: singleHorizontalStaffline
+            renderSingleHorizontalStaffline: singleHorizontalStaffline,
+            performanceMode: performanceMode
 
             // tupletsBracketed: true, // creates brackets for all tuplets except triplets, even when not set by xml
             // tripletsBracketed: true,

+ 2 - 1
package.json

@@ -23,7 +23,7 @@
     "generateSVG": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export/svg svg 0 0 allSmall --osmdtesting",
     "generatePNG:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 allSmall --debugosmdtesting",
     "generatePNG:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 ^Beethoven",
-    "generatePNG:legacyslow": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export png 0 0 all",
+    "generatePNG:legacyslow": "node ./test/Util/generateDiffImagesPuppeteerLocalhost.js ./test/data ./export 2560",
     "generatePNG:paged": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 210 297 allSmall",
     "generatePNG:paged:debug": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 210 297 all --debug 5000",
     "generatePNG:paged:single": "node ./test/Util/generateImages_browserless.js ../../build ./test/data ./export png 0 0 ^Beethoven",
@@ -65,6 +65,7 @@
   "homepage": "http://opensheetmusicdisplay.org",
   "dependencies": {
     "@types/vexflow": "^3.0.0",
+    "d-path-parser": "^1.0.0",
     "jszip": "3.4.0",
     "loglevel": "^1.6.8",
     "soundfont-player": "^0.12.0",

+ 2 - 0
src/MusicalScore/Graphical/EngravingRules.ts

@@ -301,6 +301,7 @@ export class EngravingRules {
     public NewPageAtXMLNewPageAttribute: boolean;
     public PageFormat: PageFormat;
     public PageBackgroundColor: string; // vexflow-color-string (#FFFFFF). Default undefined/transparent.
+    public PerformanceMode: boolean;
     public RenderSingleHorizontalStaffline: boolean;
     public RestoreCursorAfterRerender: boolean;
     public StretchLastSystemLine: boolean;
@@ -622,6 +623,7 @@ export class EngravingRules {
 
         this.PageFormat = PageFormat.UndefinedPageFormat; // default: undefined / 'infinite' height page, using the canvas'/container's width and height
         this.PageBackgroundColor = undefined; // default: transparent. half-transparent white: #FFFFFF88"
+        this.PerformanceMode = false;
         this.RenderSingleHorizontalStaffline = false;
         this.SpacingBetweenTextLines = 0;
 

+ 10 - 9
src/MusicalScore/Graphical/SkyBottomLineCalculator.ts

@@ -1,11 +1,11 @@
 import { EngravingRules } from "./EngravingRules";
 import { StaffLine } from "./StaffLine";
 import { PointF2D } from "../../Common/DataObjects/PointF2D";
-import { CanvasVexFlowBackend } from "./VexFlow/CanvasVexFlowBackend";
 import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import { BoundingBox } from "./BoundingBox";
+import { CanvasVexFlowBackend } from "./VexFlow";
 import { unitInPixels } from "./VexFlow/VexFlowMusicSheetDrawer";
 import log from "loglevel";
-import { BoundingBox } from "./BoundingBox";
 /**
  * This class calculates and holds the skyline and bottom line information.
  * It also has functions to update areas of the two lines if new elements are
@@ -13,13 +13,13 @@ import { BoundingBox } from "./BoundingBox";
  */
 export class SkyBottomLineCalculator {
     /** Parent Staffline where the skyline and bottom line is attached */
-    private mStaffLineParent: StaffLine;
+    protected mStaffLineParent: StaffLine;
     /** Internal array for the skyline */
-    private mSkyLine: number[];
+    protected mSkyLine: number[];
     /** Internal array for the bottomline */
-    private mBottomLine: number[];
+    protected mBottomLine: number[];
     /** Engraving rules for formatting */
-    private mRules: EngravingRules;
+    protected mRules: EngravingRules;
 
     /**
      * Create a new object of the calculator
@@ -31,7 +31,7 @@ export class SkyBottomLineCalculator {
     }
 
     /**
-     * This method calculates the Sky- and BottomLines for a StaffLine.
+     * This method calculates the Sky- and BottomLines for a StaffLine using the canvas pixel method
      */
     public calculateLines(): void {
         // calculate arrayLength
@@ -217,7 +217,6 @@ export class SkyBottomLineCalculator {
         ctx.fillRect(coord.x, coord.y, 2, 2);
         ctx.fillStyle = oldStyle;
     }
-
     /**
      * This method updates the SkyLine for a given Wedge.
      * @param start Start point of the wedge (the point where both lines meet)
@@ -453,7 +452,9 @@ export class SkyBottomLineCalculator {
      */
     public updateWithBoundingBoxRecursively(boundingBox: BoundingBox): void {
         if (boundingBox.ChildElements && boundingBox.ChildElements.length > 0) {
-            this.updateWithBoundingBoxRecursively(boundingBox);
+            for(const child of boundingBox.ChildElements){
+                this.updateWithBoundingBoxRecursively(child);
+            }
         } else {
             const currentTopBorder: number = boundingBox.BorderTop + boundingBox.AbsolutePosition.y;
             const currentBottomBorder: number = boundingBox.BorderBottom + boundingBox.AbsolutePosition.y;

+ 230 - 0
src/MusicalScore/Graphical/SkyBottomLineCalculatorSVG.ts

@@ -0,0 +1,230 @@
+import { SkyBottomLineCalculator } from "./SkyBottomLineCalculator";
+import parse from "d-path-parser";
+import { unitInPixels } from "./VexFlow/VexFlowMusicSheetDrawer";
+import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import { SvgVexFlowBackend } from "./VexFlow/SvgVexFlowBackend";
+
+export class SkyBottomLineCalculatorSVG extends SkyBottomLineCalculator {
+        private recursiveUpdate(node: SVGGraphicsElement, staveLineData: {top: number, bottom: number},
+                            measureBoundingBox: DOMRect, arrayStruct: number[][]): void {
+        const nodeBoundingBox: DOMRect = node.getBBox();
+        const nodeTop: number = nodeBoundingBox.y / unitInPixels;
+        const nodeBottom: number = nodeBoundingBox.height / unitInPixels + nodeTop;
+        const [measureSkylineArray, measureBottomLineArray]: number[][] = arrayStruct;
+        if (nodeTop < staveLineData.top || nodeBottom > staveLineData.bottom) {
+            //This node's top is above the staveline top, or the bottom is below the staveline bottom.
+            //If we are a group element, one or several of our child elements is the culprit.
+            //Otherwise, we have the node itself
+            switch (node.tagName.toLowerCase()) {
+                case "g":
+                    for (const child of node.children) {
+                        this.recursiveUpdate(child as SVGGraphicsElement, staveLineData, measureBoundingBox, arrayStruct);
+                    }
+                break;
+                //VF seems to only use path, but just in case
+                case "circle":
+                case "rect":
+                case "line":
+                case "path":
+                    let nodeLeft: number = Math.floor((nodeBoundingBox.x - measureBoundingBox.x) / unitInPixels * this.mRules.SamplingUnit);
+                    const nodeRight: number = nodeLeft + (Math.ceil(nodeBoundingBox.width / unitInPixels * this.mRules.SamplingUnit));
+
+                    if (node.parentElement.classList.contains("vf-beams") && node.hasAttribute("d")) {
+                        const dCommands: Array<{code: string, end: {x: number, y: number}, relative: boolean}> = parse(node.getAttribute("d"));
+                        //VF Beams consist of 5 commands, M, L, L, L, Z
+                        if (dCommands.length === 5) {
+                            const M: {code: string, end: {x: number, y: number}, relative: boolean} = dCommands[0];
+                            const endL: {code: string, end: {x: number, y: number}, relative: boolean} = dCommands[3];
+                            const slope: number = (endL.end.y - M.end.y)/(endL.end.x - M.end.x);
+                            let currentY: number = M.end.y / unitInPixels;
+                            for (nodeLeft; nodeLeft <= nodeRight; nodeLeft++) {
+                                if (currentY < measureSkylineArray[nodeLeft]) {
+                                    measureSkylineArray[nodeLeft] = currentY;
+                                }
+                                if (currentY > measureBottomLineArray[nodeLeft]) {
+                                    measureBottomLineArray[nodeLeft] = currentY;
+                                }
+                                currentY += slope / this.mRules.SamplingUnit;
+                            }
+                        }
+                    } else {
+                        for (nodeLeft; nodeLeft <= nodeRight; nodeLeft++) {
+                            if (nodeTop < measureSkylineArray[nodeLeft]) {
+                                measureSkylineArray[nodeLeft] = nodeTop;
+                            }
+                            if (nodeBottom > measureBottomLineArray[nodeLeft]) {
+                                measureBottomLineArray[nodeLeft] = nodeBottom;
+                            }
+                        }
+                    }
+                break;
+                default:
+                break;
+            }
+        }
+    }
+    /*TODO: This polyfill might go away. Not using it now with the 'performance mode' setting.
+    Retaining for a little while just in case.
+    protected getBBox(element: SVGGraphicsElement): DOMRect {
+        if (this.hasBBox) {
+            return element.getBBox();
+        } else if ((element as any).cachedBBox) {
+            return (element as any).cachedBBox;
+        }
+        let x: number = Number.POSITIVE_INFINITY, y: number = Number.POSITIVE_INFINITY,
+            width: number = 0, height: number = 0;
+        switch (element.tagName.toLowerCase()) {
+            case "g":
+            case "a":
+                for (const child of element.children) {
+                    const childRect: DOMRect = this.getBBox(child as SVGGraphicsElement);
+                    if (childRect.x !== Number.POSITIVE_INFINITY && childRect.y !== Number.POSITIVE_INFINITY){
+                        x = Math.min(x, childRect.x);
+                        y = Math.min(y, childRect.y);
+                        const childRight: number = childRect.x + childRect.width;
+                        const childBottom: number = childRect.y + childRect.height;
+                        width = Math.max(width, childRight - x);
+                        height = Math.max(height, childBottom - y);
+                    }
+                }
+            break;
+            // Maybe TODO. For now VF seems to just use path and rect
+            //case "text":
+            //case "polyline":
+            //case "polygon":
+            //case "ellipse":
+            //case "circle":
+            //case "line":
+            //break;
+            case "rect":
+                x = parseFloat(element.getAttribute("x"));
+                y = parseFloat(element.getAttribute("y"));
+                width = parseFloat(element.getAttribute("width"));
+                height = parseFloat(element.getAttribute("height"));
+            break;
+            case "path":
+                //For now just track end points... Calc bezier curves may be necessary
+                const dCommands: Array<{code: string, end: {x: number, y: number}, relative: boolean}> = parse(element.getAttribute("d"));
+                for (const dCommand of dCommands) {
+                    if (!dCommand.end) {
+                        continue;
+                    }
+                    x = Math.min(x, dCommand.end.x);
+                    y = Math.min(y, dCommand.end.y);
+                    width = Math.max(width, dCommand.end.x - x);
+                    height = Math.max(height, dCommand.end.y - y);
+                }
+            break;
+            default:
+            break;
+        }
+        //Due to our JSDOM tests, we can't instantiate DOMRECT directly.
+        //So we have to do it like this. Typing is enforced via the return type though.
+        (element as any).cachedBBox = {x, y, width, height};
+        return (element as any).cachedBBox;
+    } */
+
+    public calculateLinesForMeasure(measure: VexFlowMeasure, measureNode: SVGGElement): number[][] {
+        const measureBoundingBox: DOMRect = measureNode.getBBox();
+        const svgArrayLength: number = Math.max(Math.round(measure.PositionAndShape.Size.width * this.mRules.SamplingUnit), 1);
+        const measureHeight: number = measureBoundingBox.height / unitInPixels;
+        const staveLineNode: SVGGElement = measureNode.getElementsByClassName("vf-stave")[0] as SVGGElement;
+        const staveLineBoundingBox: DOMRect = staveLineNode.getBBox();
+        let staveLineHeight: number = staveLineBoundingBox?.height / unitInPixels;
+        let staveLineTop: number = staveLineBoundingBox?.y / unitInPixels;
+        const vfStave: Vex.Flow.Stave = measure.getVFStave();
+        let numLines: number = (vfStave.options?.num_lines ? vfStave.options.num_lines : 5) - 1;
+        let topLine: number = -1;
+        let lineIndex: number = 0;
+        const bottomLineQueue: number[] = [numLines];
+        for (const config of (vfStave.options as any)?.line_config) {
+            if (!config.visible) {
+                numLines--;
+            } else {
+                if (topLine === -1) {
+                    topLine = lineIndex;
+                }
+                bottomLineQueue.push(lineIndex);
+            }
+            lineIndex++;
+        }
+        const bottomLine: number = bottomLineQueue.pop();
+        if (topLine === -1) {
+            topLine = 0;
+        }
+        numLines = bottomLine - topLine;
+
+        const lineSpacing: number = vfStave.options?.spacing_between_lines_px;
+        const vfLinesHeight: number = numLines * lineSpacing / unitInPixels;
+        if ((staveLineHeight - vfLinesHeight) > 0.2) {
+            staveLineHeight = vfLinesHeight;
+            staveLineTop = topLine * lineSpacing / unitInPixels;
+        }
+
+        const staveLineBottom: number = staveLineTop + staveLineHeight;
+        const measureSkylineArray: number[] = new Array(svgArrayLength).fill(staveLineTop);
+        const measureBottomlineArray: number[] = new Array(svgArrayLength).fill(staveLineBottom);
+        const arrayStruct: number[][] = [measureSkylineArray, measureBottomlineArray];
+        if (measureHeight > staveLineHeight) {
+            for(const child of measureNode.children){
+                    this.recursiveUpdate(child as SVGGraphicsElement, {top: staveLineTop, bottom: staveLineBottom},
+                                         measureBoundingBox, [measureSkylineArray, measureBottomlineArray]);
+
+            }
+        }
+        return arrayStruct;
+    }
+
+    /**
+     * This method calculates the Sky- and BottomLines for a StaffLine using SVG
+     */
+    public calculateLines(): void {
+        this.mSkyLine = [];
+        this.mBottomLine = [];
+        const invisibleSVG: HTMLDivElement = document.createElement("div");
+        document.body.append(invisibleSVG);
+        const svgBackend: SvgVexFlowBackend = new SvgVexFlowBackend(this.mRules);
+        svgBackend.initialize(invisibleSVG, 1, "0");
+        const context: Vex.Flow.SVGContext = svgBackend.getContext();
+        // search through all Measures
+        const stafflineNode: SVGGElement = context.openGroup() as SVGGElement;
+        stafflineNode.classList.add("staffline");
+        for (const measure of this.StaffLineParent.Measures as VexFlowMeasure[]) {
+            // must calculate first AbsolutePositions
+            measure.PositionAndShape.calculateAbsolutePositionsRecursive(0, 0);
+            measure.setAbsoluteCoordinates(
+                measure.PositionAndShape.AbsolutePosition.x * unitInPixels,
+                measure.PositionAndShape.AbsolutePosition.y * unitInPixels
+            );
+            const measureElement: SVGGElement = measure.draw(context) as SVGGElement;
+            const [measureSkylineArray, measureBottomLineArray]: number[][] = this.calculateLinesForMeasure(measure, measureElement);
+            this.mSkyLine.push(...measureSkylineArray);
+            this.mBottomLine.push(...measureBottomLineArray);
+        }
+        context.closeGroup();
+        //Since ties can span multiple measures, process them after the whole staffline has been processed
+        for (const tieGroup of stafflineNode.getElementsByClassName("vf-ties")) {
+            for (const tie of tieGroup.childNodes) {
+                if (tie.nodeName.toLowerCase() === "path") {
+                    //TODO: calculate bezier curve? Probably not necessary since ties by their nature will not slope widely
+                    const nodeBoundingBox: DOMRect = (tie as SVGPathElement).getBBox();
+                    let nodeLeft: number = Math.floor(nodeBoundingBox.x / unitInPixels * this.mRules.SamplingUnit);
+                    const nodeRight: number = nodeLeft + (Math.ceil(nodeBoundingBox.width / unitInPixels * this.mRules.SamplingUnit));
+                    const nodeTop: number = nodeBoundingBox.y / unitInPixels;
+                    const nodeBottom: number = nodeBoundingBox.height / unitInPixels + nodeTop;
+
+                    for (nodeLeft; nodeLeft <= nodeRight; nodeLeft++) {
+                        if (nodeTop < this.mSkyLine[nodeLeft]) {
+                            this.mSkyLine[nodeLeft] = nodeTop;
+                        }
+                        if (nodeBottom > this.mBottomLine[nodeLeft]) {
+                            this.mBottomLine[nodeLeft] = nodeBottom;
+                        }
+                    }
+                }
+            }
+        }
+        svgBackend.clear();
+        invisibleSVG.remove();
+    }
+}

+ 2 - 1
src/MusicalScore/Graphical/StaffLine.ts

@@ -13,6 +13,7 @@ import { SkyBottomLineCalculator } from "./SkyBottomLineCalculator";
 import { GraphicalOctaveShift } from "./GraphicalOctaveShift";
 import { GraphicalSlur } from "./GraphicalSlur";
 import { AbstractGraphicalExpression } from "./AbstractGraphicalExpression";
+import { MusicSheetCalculator } from "./MusicSheetCalculator";
 
 /**
  * A StaffLine contains the [[Measure]]s in one line of the music sheet
@@ -40,7 +41,7 @@ export abstract class StaffLine extends GraphicalObject {
         this.parentMusicSystem = parentSystem;
         this.parentStaff = parentStaff;
         this.boundingBox = new BoundingBox(this, parentSystem.PositionAndShape);
-        this.skyBottomLine = new SkyBottomLineCalculator(this);
+        this.skyBottomLine = MusicSheetCalculator.symbolFactory.createSkyBottomLineCalculator(this);
         this.staffHeight = this.parentMusicSystem.rules.StaffHeight;
         this.topLineOffset = 0;
         this.bottomLineOffset = 4;

+ 6 - 3
src/MusicalScore/Graphical/VexFlow/SvgVexFlowBackend.ts

@@ -32,10 +32,13 @@ export class SvgVexFlowBackend extends VexFlowBackend {
         return document.getElementById("osmdCanvasPage" + this.graphicalMusicPage.PageNumber)?.offsetHeight;
     }
 
-    public initialize(container: HTMLElement, zoom: number): void {
+    public initialize(container: HTMLElement, zoom: number, id: string = undefined): void {
         this.zoom = zoom;
         this.canvas = document.createElement("div");
-        this.canvas.id = "osmdCanvasPage" + this.graphicalMusicPage.PageNumber;
+        if (!id) {
+            id = this.graphicalMusicPage ? this.graphicalMusicPage.PageNumber.toString() : "1";
+        }
+        this.canvas.id = "osmdCanvasPage" + id;
         // this.canvas.id = uniqueID // TODO create unique tagName like with cursor now?
         this.inner = this.canvas;
         this.inner.style.position = "relative";
@@ -43,7 +46,7 @@ export class SvgVexFlowBackend extends VexFlowBackend {
         container.appendChild(this.inner);
         this.renderer = new Vex.Flow.Renderer(this.canvas, this.getVexflowBackendType());
         this.ctx = <Vex.Flow.SVGContext>this.renderer.getContext();
-        this.ctx.svg.id = "osmdSvgPage" + this.graphicalMusicPage.PageNumber;
+        this.ctx.svg.id = "osmdSvgPage" + id;
     }
 
     public getContext(): Vex.Flow.SVGContext {

+ 25 - 0
src/MusicalScore/Graphical/VexFlow/VexFlowGraphicalSymbolFactory.ts

@@ -29,8 +29,19 @@ import { VexFlowTabMeasure } from "./VexFlowTabMeasure";
 import { VexFlowStaffLine } from "./VexFlowStaffLine";
 import { KeyInstruction } from "../../VoiceData/Instructions/KeyInstruction";
 import { VexFlowMultiRestMeasure } from "./VexFlowMultiRestMeasure";
+import { SkyBottomLineCalculator } from "../SkyBottomLineCalculator";
+import { SkyBottomLineCalculatorSVG } from "../SkyBottomLineCalculatorSVG";
 
 export class VexFlowGraphicalSymbolFactory implements IGraphicalSymbolFactory {
+
+    private hasBBox: boolean = false;
+    constructor() {
+        //Need to test this way for JSDOM for visual tests
+        const groupTest: SVGGElement = document.createElementNS("http://www.w3.org/2000/svg", "g");
+        if (groupTest.getBBox !== undefined) {
+            this.hasBBox = true;
+        }
+    }
     /**
      * Create a new music system for the given page.
      * Currently only one vertically endless page exists where all systems are put to.
@@ -53,6 +64,20 @@ export class VexFlowGraphicalSymbolFactory implements IGraphicalSymbolFactory {
     }
 
     /**
+     * Create a SkyBottomLine calculator for a given staffline
+     * @param parentSystem
+     * @param parentStaff
+     * @returns {VexFlowStaffLine}
+     */
+     public createSkyBottomLineCalculator(parentStaffline: StaffLine): SkyBottomLineCalculator {
+        if (parentStaffline.ParentMusicSystem.rules.PerformanceMode && this.hasBBox) {
+            return new SkyBottomLineCalculatorSVG(parentStaffline);
+        } else {
+            return new SkyBottomLineCalculator(parentStaffline);
+        }
+    }
+
+    /**
      * Construct an empty graphicalMeasure from the given source measure and staff.
      * @param sourceMeasure
      * @param staff

+ 19 - 3
src/MusicalScore/Graphical/VexFlow/VexFlowMeasure.ts

@@ -572,10 +572,17 @@ export class VexFlowMeasure extends GraphicalMeasure {
      * Draw this measure on a VexFlow CanvasContext
      * @param ctx
      */
-    public draw(ctx: Vex.IRenderContext): void {
+    public draw(ctx: Vex.IRenderContext): Node {
+        const measureNode: SVGGElement = ctx.openGroup() as SVGGElement;
+        measureNode?.classList?.add("vf-measure");
 
+        const staveLineNode: Node = ctx.openGroup();
+        (staveLineNode as SVGGElement)?.classList?.add("vf-stave");
         // Draw stave lines
         this.stave.setContext(ctx).draw();
+        ctx.closeGroup();
+        const voicesNode: Node = ctx.openGroup();
+        (voicesNode as SVGGElement)?.classList?.add("vf-voices");
         // Draw all voices
         for (const voiceID in this.vfVoices) {
             if (this.vfVoices.hasOwnProperty(voiceID)) {
@@ -586,6 +593,9 @@ export class VexFlowMeasure extends GraphicalMeasure {
                 // this.vfVoices[voiceID].tickables.forEach(t => t.getBoundingBox().draw(ctx));
             }
         }
+        ctx.closeGroup();
+        const beamsNode: Node = ctx.openGroup();
+        (beamsNode as SVGGElement)?.classList?.add("vf-beams");
         // Draw beams
         for (const voiceID in this.vfbeams) {
             if (this.vfbeams.hasOwnProperty(voiceID)) {
@@ -615,17 +625,23 @@ export class VexFlowMeasure extends GraphicalMeasure {
                 }
             }
         }
-
+        ctx.closeGroup();
+        //Close the measure group
+        ctx.closeGroup();
+        //Ties need special treatment, should not be part of hte measure bounding box
+        const tieNode: Node = ctx.openGroup();
+        (tieNode as SVGGElement)?.classList?.add("vf-ties");
         // Draw ties
         for (const tie of this.vfTies) {
             tie.setContext(ctx).draw();
         }
-
+        ctx.closeGroup();
         // Draw vertical lines
         for (const connector of this.connectors) {
             connector.setContext(ctx).draw();
         }
         this.correctNotePositions();
+        return measureNode;
     }
 
     // this currently formats multiple measures, see VexFlowMusicSheetCalculator.formatMeasures()

+ 11 - 3
src/MusicalScore/Graphical/VexFlow/VexFlowMultiRestMeasure.ts

@@ -45,18 +45,26 @@ export class VexFlowMultiRestMeasure extends VexFlowMeasure {
      * Draw this measure on a VexFlow CanvasContext
      * @param ctx
      */
-    public draw(ctx: Vex.IRenderContext): void {
+    public draw(ctx: Vex.IRenderContext): Node {
+        const measureNode: SVGGElement = ctx.openGroup() as SVGGElement;
+        measureNode?.classList?.add("vf-measure");
+        const staveLineNode: Node = ctx.openGroup();
+        (staveLineNode as SVGGElement)?.classList?.add("vf-stave");
         // Draw stave lines
         this.stave.setContext(ctx).draw();
-
+        ctx.closeGroup();
+        const voicesNode: Node = ctx.openGroup();
+        (voicesNode as SVGGElement)?.classList?.add("vf-voices");
         this.multiRestElement.setStave(this.stave);
         this.multiRestElement.setContext(ctx);
         this.multiRestElement.draw();
-
+        ctx.closeGroup();
         // Draw vertical lines
         for (const connector of this.connectors) {
             connector.setContext(ctx).draw();
         }
+        ctx.closeGroup();
+        return measureNode;
     }
 
     public format(): void {

+ 7 - 2
src/MusicalScore/Graphical/VexFlow/VexFlowMusicSheetDrawer.ts

@@ -117,11 +117,16 @@ export class VexFlowMusicSheetDrawer extends MusicSheetDrawer {
     }
 
     protected drawStaffLine(staffLine: StaffLine): void {
+        const stafflineNode: Node = this.backend.getContext().openGroup();
+        if (stafflineNode) {
+            (stafflineNode as SVGGElement).classList.add("staffline");
+        }
         super.drawStaffLine(staffLine);
         const absolutePos: PointF2D = staffLine.PositionAndShape.AbsolutePosition;
         if (this.rules.RenderSlurs) {
             this.drawSlurs(staffLine as VexFlowStaffLine, absolutePos);
         }
+        this.backend.getContext().closeGroup();
     }
 
     private drawSlurs(vfstaffLine: VexFlowStaffLine, absolutePos: PointF2D): void {
@@ -174,13 +179,13 @@ export class VexFlowMusicSheetDrawer extends MusicSheetDrawer {
             measure.PositionAndShape.AbsolutePosition.x * unitInPixels,
             measure.PositionAndShape.AbsolutePosition.y * unitInPixels
         );
+        const context: Vex.IRenderContext = this.backend.getContext();
         try {
-            measure.draw(this.backend.getContext());
+            measure.draw(context);
             // Vexflow errors can happen here. If we don't catch errors, rendering will stop after this measure.
         } catch (ex) {
             log.warn("VexFlowMusicSheetDrawer.drawMeasure", ex);
         }
-
         // Draw the StaffEntries
         for (const staffEntry of measure.staffEntries) {
             this.drawStaffEntry(staffEntry);

+ 3 - 0
src/MusicalScore/Interfaces/IGraphicalSymbolFactory.ts

@@ -16,6 +16,7 @@ import { GraphicalVoiceEntry } from "../Graphical/GraphicalVoiceEntry";
 import { VoiceEntry } from "../VoiceData/VoiceEntry";
 import { EngravingRules } from "../Graphical/EngravingRules";
 import { KeyInstruction } from "../VoiceData/Instructions/KeyInstruction";
+import { SkyBottomLineCalculator } from "../Graphical/SkyBottomLineCalculator";
 
 export interface IGraphicalSymbolFactory {
 
@@ -23,6 +24,8 @@ export interface IGraphicalSymbolFactory {
 
     createStaffLine(parentSystem: MusicSystem, parentStaff: Staff): StaffLine;
 
+    createSkyBottomLineCalculator(parentStaffline: StaffLine): SkyBottomLineCalculator;
+
     createGraphicalMeasure(sourceMeasure: SourceMeasure, staff: Staff): GraphicalMeasure;
 
     createMultiRestMeasure(sourceMeasure: SourceMeasure, staff: Staff): GraphicalMeasure;

+ 7 - 0
src/OpenSheetMusicDisplay/OSMDOptions.ts

@@ -164,6 +164,13 @@ export interface IOSMDOptions {
      * (will be fixed at some point).
      */
     pageBackgroundColor?: string;
+    /** This option enables or disables "performance" mode where skybottom lines are calculated using the SVG bounding box engine
+     * instead of the canvas pixel method. This confers a 2-3x performance increase depending on the browser used.
+     * This will only be work if the environment supports SVG's getBBox method - Otherwise it will default back to the canvas method.
+     * Defaults to off.
+     * (Note this won't ever be used in the visual regression headless mode.)
+     */
+    performanceMode?: boolean;
     /** This makes OSMD render on one single horizontal (staff-)line.
      * This option should be set before loading a score. It only starts working after load(),
      * calling setOptions() after load and then render() doesn't work in this case.

+ 3 - 0
src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts

@@ -649,6 +649,9 @@ export class OpenSheetMusicDisplay {
         if (options.pageBackgroundColor !== undefined) {
             this.rules.PageBackgroundColor = options.pageBackgroundColor;
         }
+        if (options.performanceMode !== undefined) {
+            this.rules.PerformanceMode = options.performanceMode;
+        }
         if (options.renderSingleHorizontalStaffline !== undefined) {
             this.rules.RenderSingleHorizontalStaffline = options.renderSingleHorizontalStaffline;
         }

+ 13 - 6
test/Util/generateDiffImagesPuppeteerLocalhost.js

@@ -45,9 +45,11 @@ async function init () {
     let pageFormatParameter = ''
     pageHeight = Number.parseInt(pageHeight)
     pageWidth = Number.parseInt(pageWidth)
-    const endlessPage = !(pageHeight > 0 && pageWidth > 0)
-    if (!endlessPage) {
-        pageFormatParameter = `&pageWidth=${pageWidth}&pageHeight=${pageHeight}`
+    if(!pageWidth || pageWidth === NaN){
+        pageWidth = 800
+    }
+    if(!pageHeight || pageHeight === NaN){
+        pageHeight = 600
     }
 
     const DEBUG = debugFlag === '--debug'
@@ -96,9 +98,14 @@ async function init () {
 
     const puppeteer = require('puppeteer')
     const browser = await puppeteer.launch({ headless: true })
-    const page = await browser.newPage() // TODO set width/height
-
-    const defaultTimeoutInMs = 30000
+    const page = await browser.newPage()
+    page.setViewport({
+        width: pageWidth,
+        height: pageHeight,
+        deviceScaleFactor: 1
+    });
+
+    const defaultTimeoutInMs = 100000
     page.setDefaultNavigationTimeout(defaultTimeoutInMs) // default setting for page navigationtimeout is 30000ms.
 
     // fix navigation error