Skip to content

Instantly share code, notes, and snippets.

@akre54
Last active January 20, 2023 02:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save akre54/4d9ace17fb27d0507a6c790be5047e3d to your computer and use it in GitHub Desktop.
Save akre54/4d9ace17fb27d0507a6c790be5047e3d to your computer and use it in GitHub Desktop.
TouchDesigner SVG to SOP and After Effects Bodymovin / Lottie to TD-friendly SVG
const fs = require('node:fs');
const cp = require('node:child_process');
const path = require('node:path');
const { optimize } = require('svgo');
const renderSvg = require('lottie-to-svg');
const paper = require('paper');
const { JSDOM } = require('jsdom');
const svgFlatten = require('svg-flatten');
const replaceUse = require('./replace-use');
console.log('Usage: `node index.js <path-to-lottie-json> <path-to-output-svg>`');
const [input, output, frame = 'all'] = process.argv.slice(2);
console.log(input, output, frame);
const animationData = JSON.parse(fs.readFileSync(input, 'utf8'));
const totalFrames = Math.round(animationData.op - animationData.ip);
const dirname = path.resolve('./output', output);
paper.setup([1, 1]);
paper.view.autoUpdate = false;
cp.execSync(`mkdir -p ${dirname}`);
if (frame === 'all') {
for (let i = 0; i < totalFrames; i++) {
const filename = path.resolve(dirname, `${output}-${i}.svg`);
processFrame(animationData, filename, i);
}
} else {
const filename = path.resolve(dirname, `${output}-${frame}.svg`);
processFrame(animationData, filename, Number(frame));
}
async function processFrame(animationData, filename, i) {
let svgStr = await renderSvg(animationData, {}, i);
svgStr = substUsed(svgStr);
svgStr = optimize(svgStr, {
multipass: true,
plugins: [
// replaceUse,
'removeOffCanvasPaths',
{
name: 'preset-default',
params: {
overrides: {
convertPathData: {
forceAbsolutePath: true,
makeArcs: false,
}
}
}
},
]
}).data;
svgStr = svgFlatten(svgStr).transform().pathify().value();
svgStr = clipSVGPaths(svgStr);
svgStr = optimize(svgStr, {
plugins: [
'collapseGroups'
]
}).data;
fs.writeFileSync(filename, svgStr, 'utf-8');
console.log(`Wrote frame ${i} of ${totalFrames}`);
}
function substUsed(svgString) {
const dom = new JSDOM(svgString);
const document = dom.window.document;
const svg = document.querySelector('svg');
svg.querySelectorAll('use').forEach(el => {
const ref = svg.querySelector(el.getAttribute('href')).cloneNode(true);
ref.removeAttribute('id');
el.replaceWith(ref);
});
return svg.outerHTML;
}
function clipSVGPaths(svgString) {
const dom = new JSDOM(svgString);
const document = dom.window.document;
const svg = document.querySelector('svg');
const clipPathElements = svg.querySelectorAll('[clip-path], [mask]');
const elementMap = new Map();
clipPathElements.forEach(element => {
const clipPathAttr = element.getAttribute('clip-path') || element.getAttribute('mask');
// (e.g. "url(#elementId)")
const elementId = clipPathAttr.match(/\#(.+?)\)/)[1];
const clipPathElement = document.getElementById(elementId);
if (!elementMap.has(elementId)) {
const pathElements = clipPathElement.querySelectorAll('path');
const path = new paper.Path(
[].map.call(pathElements, p => p.getAttribute('d')).join(' ')
);
elementMap.set(elementId, path.pathData);
}
});
clipPathElements.forEach(element => {
const clipPathAttr = element.getAttribute('clip-path') || element.getAttribute('mask');
const elementId = clipPathAttr.match(/\#(.+?)\)/)[1];
const clipPathData = elementMap.get(elementId);
element.removeAttribute('clip-path');
element.removeAttribute('mask');
const pathElements = element.querySelectorAll('path');
const path = new paper.Path(
[].map.call(pathElements, p => p.getAttribute('d')).join(' ')
);
// pathElements.forEach(n => n.remove());
// const visible = viewBox.intersect(path);
const clipPath = new paper.Path(clipPathData);
const intersection = path.intersect(clipPath);
const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathElement.setAttribute('d', intersection.pathData);
pathElement.setAttribute('fill', 'none');
pathElement.setAttribute('stroke', 'red');
pathElement.setAttribute('data-clipped', true);
element.appendChild(pathElement);
});
const defs = svg.querySelector('defs');
if (defs) {
defs.remove();
}
svg.querySelectorAll('path[fill="#FFF"]').forEach(n => n.remove())
return svg.outerHTML;
}
'use strict';
const { querySelector } = require('svgo/lib/xast');
exports.name = 'replace-use';
exports.description = 'replace all <use> elements with the node they clone';
/**
* Replace <use> elements with the nodes they clone and remove the top-level xlink attribute and remove their referenced element if it's within <defs>. While this doesn't "optimize" the SVG, it allows the contents to be used in SVG sprites within <symbol> elements.
*
* @author Tim Shedor
* @author Adam Krebs
*/
exports.fn = () => {
const defs = new Map;
const used = new Set;
return {
element: {
enter(node, parentNode) {
if (node.name === 'svg') {
delete node.attributes['xmlns:xlink'];
return
}
if (parentNode.name === 'defs') {
defs.set('#' + node.attributes.id, node);
return;
}
if (node.name === 'use') {
const id = node.attributes.href || node.attributes['xlink:href'];
const def = defs.get(id);
if (!def) {
console.warn(`Could not find definition for ${id}`);
return;
}
delete node.attributes['xlink:href'];
delete node.attributes['href'];
node.name = 'g';
node.children = def.children.slice();
used.add(def);
}
},
},
root: {
exit(root) {
const defs = querySelector(root, 'defs')
if (defs) {
defs.children = defs.children.filter(node => !used.has(node));
if (!defs.children.length) {
defs.parentNode.children = defs.parentNode.children.filter(node => node !== defs);
}
}
}
}
}
}
from xml.dom.minidom import parseString
from io import StringIO
from svgpathtools import parse_path, Arc, CubicBezier, Line
# press 'Setup Parameters' in the OP to call this function to re-create the parameters
def onSetupParameters(scriptOp):
page = scriptOp.appendCustomPage('Controls')
p = page.appendOP('Svgop', label='SVG DAT Op')
return
# called whenever custom pulse parameter is pushed
def onPulse(par):
return
def onCook(scriptOp):
scriptOp.clear()
text = op(scriptOp.par.Svgop.eval()).text
doc = parseString(text)
path_strings = [path.getAttribute('d') for path
in doc.getElementsByTagName('path')]
doc.unlink()
for d in path_strings:
path = parse_path(d)
for segment in path:
if isinstance(segment, CubicBezier):
b = scriptOp.appendBezier(4)
b[0].point.x = segment.start.real
b[0].point.y = segment.start.imag
b[1].point.x = segment.control1.real
b[1].point.y = segment.control1.imag
b[2].point.x = segment.control2.real
b[2].point.y = segment.control2.imag
b[3].point.x = segment.end.real
b[3].point.y = segment.end.imag
elif isinstance(segment, Line):
l = scriptOp.appendPoly(2)
l[0].point.x = segment.start.real
l[0].point.y = segment.start.imag
l[1].point.x = segment.end.real
l[1].point.y = segment.end.imag
elif isinstance(segment, Arc):
print('Segment - TODO')
print(segment)
else:
print('Other')
return
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment