javascriptsvgblinksmil

Capture chrome browser engine output using javascript in order to capture rasterized frames of an SMIL SVG animation


My ultimate goal is that I'm trying to convert an animated SMIL SVG into an APNG file. I have found no easy way to do this, and so I'm doing something a roundabout: I've written a node.js + express.js app that hosts a simple backend to get svg images on my local filesystem, and I've written a vue.js app that will go and pull those images and render them on a google chrome browser. I then play the SVG and try to capture rendered "frames", and save those frames as static png files (about 30 static PNG files for each second of SVG animation). I then plan to take those static png files & convert them over to a single animated png / apng file using another program. The part that I'm stuck on: actually trying to capture a rasterized "frame" of the svg.

Here's a snippet of code from my vue.js app which requests an SVG file, and renders it to a div, and then it tries to call a function takeSnap().

    const file = await RequestsService.getFile(i);
    const div = document.getElementById("svgContainer");
    div.innerHTML = file.svg;

    const { width, height } = div.children[0].getBBox();
    console.debug(`width: ${width}, height: ${height}`);
    const svg = div.children[0];
    await svg.pauseAnimations();
    let time = 0.0;
    const interval = 1.0 / numFrames; // interval in seconds.
    let count = 0;
    while (time < file.duration) {
      console.log(`time=${time}`);
      await svg.setCurrentTime(time);
      await this.takeSnap(svg, width, height);
      time += interval;
      console.debug(`file: ${file.fileName}_${count}`);
    }
    await svg.setCurrentTime(file.duration);
    await this.takeSnap(svg, width, height);

I haven't been able to make a proper implementation of takeSnap(). I know that there are a slew of tools such as Canvg or HTML2png that go and directly render a webpage from the DOM. I've tried many different libraries, but none of them seem to be able to correctly render the frame of the SVG that chrome is correctly rendering. I don't blame the libraries: going from animated SVG XML file to actually rasterized pixels is a very difficult problem I think. But Chrome can do it, and what I'm wondering is... can I capture the browser engine output of chrome somehow?

Is there a way that I can get the rasterized pixel data produced by the blink browser engine in chrome & then save that rasterized pixel data into a png file? I know that I'll lose the transparency data of the SVG, but that's okay, I'll work around that later.


Solution

  • OK, this got a bit complicated. The script can now take SMIL animations with both <animate> and <animateTransform>. Essentially I take a snap shot of the SVG using Window.getComputedStyle() (for <animate> elements) and the matrix value using SVGAnimatedString.animVal (for <animateTransform> elements). A copy of the SVG is turned into a data URL and inserted into a <canvas>. From here it is exported as a PNG image.

    In this example I use a data URL in the fetch function, but this can be replaced by a URL. The script has been tested with the SVG that OP provided.

    var svgcontainer, svg, canvas, ctx, output, interval;
    var num = 101;
    
    const nsResolver = prefix => {
      var ns = {
        'svg': 'http://www.w3.org/2000/svg',
        'xlink': 'http://www.w3.org/1999/xlink'
      };
      return ns[prefix] || null;
    };
    
    const takeSnap = function() {
      // get all animateTransform elements
      let animateXPath = document.evaluate('//svg:*[svg:animateTransform]', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    
      // store all animateTransform animVal.matrix in a dataset attribute
      Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
        let node = animateXPath.snapshotItem(i);
        let mStr = [...node.transform.animVal].map(animVal => {
          let m = animVal.matrix;
          return `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
        }).join(' ');
        node.dataset.transform = mStr;
      });
    
      // get all animate elements
      animateXPath = document.evaluate('//svg:animate', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    
      // store all animate properties in a dataset attribute on the target for the animation
      Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
        let node = animateXPath.snapshotItem(i);
        let propName = node.getAttribute('attributeName');
        let target = node.targetElement;
        let computedVal = getComputedStyle(target)[propName];
        target.dataset[propName] = computedVal;
      });
    
      // create a copy of the SVG DOM
      let parser = new DOMParser();
      let svgcopy = parser.parseFromString(svg.outerHTML, "application/xml");
    
      // find all elements with a dataset attribute
      animateXPath = svgcopy.evaluate('//svg:*[@*[starts-with(name(), "data")]]', svgcopy, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    
      // copy the animated property to a style or attribute on the same element
      Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
        let node = animateXPath.snapshotItem(i);
        // for each data-
        for (key in node.dataset) {
          if (key == 'transform') {
            node.setAttribute(key, node.dataset[key]);
          } else {
            node.style[key] = node.dataset[key];
          }
        }
      });
    
      // find all animate and animateTransform elements from the copy document
      animateXPath = svgcopy.evaluate('//svg:*[starts-with(name(), "animate")]', svgcopy, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    
      // remove all animate and animateTransform elements from the copy document
      Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
        let node = animateXPath.snapshotItem(i);
        node.remove();
      });
    
      // create a File object
      let file = new File([svgcopy.rootElement.outerHTML], 'svg.svg', {
        type: "image/svg+xml"
      });
      // and a reader
      let reader = new FileReader();
    
      reader.addEventListener('load', e => {
        /* create a new image assign the result of the filereader
        to the image src */
        let img = new Image();
        // wait got load
        img.addEventListener('load', e => {
          // update canvas with new image
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.fillStyle = 'white';
          ctx.fillRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(e.target, 0, 0);
          // create PNG image based on canvas
          let img = new Image();
          img.src = canvas.toDataURL("image/png");
          output.append(img);
          //let a = document.createElement('A');
          //a.textContent = `Image-${num}`;
          //a.href = canvas.toDataURL("image/png");
          //a.download = `Image-${num}`; 
          //num++;
          //output.append(a);
        });
        img.src = e.target.result;
      });
      // read the file as a data URL
      reader.readAsDataURL(file);
    };
    
    document.addEventListener('DOMContentLoaded', e => {
      svgcontainer = document.getElementById('svgcontainer');
      canvas = document.getElementById('canvas');
      output = document.getElementById('output');
      ctx = canvas.getContext('2d');
    
      fetch('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMCAxMCI+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0ibmF2eSI+CiAgICA8YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJmaWxsIiBkdXI9IjNzIiBrZXlUaW1lcz0iMDsuMTsuMjsxIiB2YWx1ZXM9Im5hdnk7bGlnaHRibHVlO2JsdWU7bmF2eSIvPgogICAgPGFuaW1hdGVUcmFuc2Zvcm0gYWRkaXRpdmU9InN1bSIgYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiBkdXI9IjNzIiBrZXlUaW1lcz0iMDsuNTsxIiB0eXBlPSJ0cmFuc2xhdGUiIHZhbHVlcz0iMCwwOzIsMjs0LDYiLz4KICAgIDxhbmltYXRlVHJhbnNmb3JtIGFkZGl0aXZlPSJzdW0iIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgZHVyPSIzcyIga2V5VGltZXM9IjA7LjU7MSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjAsMiwyOzMwLDIsMjstMTAsMiwyOyIvPgogIDwvcmVjdD4KPC9zdmc+CgoK').then(res => res.text()).then(text => {
        let parser = new DOMParser();
        let svgdoc = parser.parseFromString(text, "application/xml");
        canvas.width = svgdoc.rootElement.getAttribute('width');
        canvas.height = svgdoc.rootElement.getAttribute('height');
    
        svgcontainer.innerHTML = svgdoc.rootElement.outerHTML;
        svg = svgcontainer.querySelector('svg');
    
        // set interval
        interval = setInterval(takeSnap, 50);
    
        // get all 
        let animateXPath = document.evaluate('//svg:*[starts-with(name(), "animate")]', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
    
        let animationArr = Object.keys([...Array(animateXPath.snapshotLength)]).map(i => {
          let node = animateXPath.snapshotItem(i);
          return new Promise((resolve, reject) => {
            node.addEventListener('endEvent', e => {
              resolve();
            });
          });
        });
        Promise.all(animationArr).then(value => {
          clearInterval(interval);
        });
      });
    });
    <div style="display:flex">
      <div id="svgcontainer"></div>
      <canvas id="canvas" width="200" height="200"></canvas>
    </div>
    <p>Exported PNGs:</p>
    <div id="output"></div>