1

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.

SomeGuy
  • 1,702
  • 1
  • 20
  • 19
  • 1
    Does is have to be SMIL or can it be done in JavaScript? JavaScript has the advantage that it changes the DOM, so one could take a snap shot of that. – chrwahl Jun 15 '22 at 10:18
  • I'm not sure I understand your question. I'm given an SVG file animated with SMIL, so that's the input. I'm trying to use JavaScript to take a snapshot, but every JavaScript library I've tried that takes snapshots tries to generate a rasterized image from the DOM. All of these end up not working well for an SVG image that's in the middle of being animated. So I'm hoping to try to capture the output of Chrome's rendering engine instead, since Chrome's rendering engine rasterized the animated SVG just fine. – SomeGuy Jun 15 '22 at 11:30
  • 1
    If the SVG is animated using JavaScript it is easier to get the current state in the animation because the DOM of the SVG is changing during the animation. With SMIL animations you need to get the computed style of each animated element like in the answer that I posted https://stackoverflow.com/a/72631057/322084 – chrwahl Jun 15 '22 at 12:24
  • I see, I didn't realize it was possible to just use JavaScript to animate SVG. I wish the SVG images I have used that haha. Unfortunately it's all SMIL and I have no control over that. That's for all 300+ SVG images I have. – SomeGuy Jun 15 '22 at 14:34
  • 1
    Do you know if they use all the different SVGAnimation elements: ``, `` and ``? My answer just handles ``, and I can see that `` works in a different way, so one has to come up with another solution to capture the state of the animated element.... – chrwahl Jun 15 '22 at 15:08
  • sorry for the late response. I just wrote & ran a quick python script to parse through all of the SVG images that I have to go and count usages of those tags. Searching for the words "animate", "animatemotion", and "animatetransform", only the word "animate" appears... so this means your solution below will probably work. I will try to use your implementation and keep you posted. Thanks for all your effort, it's more than I would have expected. – SomeGuy Jun 16 '22 at 00:07

1 Answers1

2

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>
chrwahl
  • 8,675
  • 2
  • 20
  • 30
  • For some strange reason, some of the animations end up getting ignored. So there are some SVG objects that sort of remain stationary, and other elements move as they should. I have had this issue with every single SVG to image library that I've tried. I'm not sure what's wrong. But I can see that chrome knows how to render the animations properly, and so I guess I just have to capture output from the rendering engine somehow. – SomeGuy Jun 16 '22 at 01:06
  • 1
    @SomeGuy Can you share one of the SVGs so I can have a look? – chrwahl Jun 16 '22 at 05:38
  • sure, here's the one that I've been testing: https://drive.google.com/file/d/1Y1md1WeZQMc4XrtmC5wBwIBqMrlNXie8/view?usp=sharing – SomeGuy Jun 16 '22 at 05:46
  • 1
    There are ``s in the document. And these are left out in my script. I researched a bit and haven't found a solution. The closest I get is "During CSS transitions, getComputedStyle returns the original property value in Firefox, but the final property value in WebKit." https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle – that is addressing "CSS transitions", but my guess would be that it is the same with SVGAnimateTransform as we talk about here. So, now way to get the values during the animation. You need another kind of approach. – chrwahl Jun 16 '22 at 07:44
  • Honestly, the fact that your code and all the libraries I've tried produce the same result might be an indication that this just currently isn't possible. Also, sorry about the bad info earlier when I said there weren't any tags. I just wish there was a way to capture the actual pixels that got rendered to screen since the browser engine already did the work of rasterizing the animated SVG. – SomeGuy Jun 16 '22 at 21:52
  • 1
    @SomeGuy I updated my answer. The script can now take SMIL animations with both animate and animateTransform. – chrwahl Jun 22 '22 at 20:21
  • this worked perfectly! I ended up using `svg.pauseAnimations();` & `svg.setCurrentTime(time);` so that I could get the SVG snapshots at exact timeframes, even on my slow computer, instead of the `interval = setInterval(takeSnap, 50);` & `clearInterval(interval);` that you went with. But other than that, I kept your code the same for the most part & integrated it into my vue.js app. Thanks again, I would _not_ have figured out how to do this. – SomeGuy Jun 25 '22 at 11:15
  • 1
    @SomeGuy I'm happy to hear that. Yes, `setInterval()` should definite be replaced. YOu can also consider [requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) and maybe even [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) and [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) if the performance is an issue. – chrwahl Jun 25 '22 at 11:30