Last active
April 12, 2025 00:55
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'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); | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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