@bevyl-ai/ts-aep
v0.6.0
Published
1:1 TypeScript port of py-aep: read and write Adobe After Effects .aep projects (Node, Bun, browsers, Workers)
Maintainers
Readme
ts-aep
An AI rewrite of the amazing
py-aep package, the After Effects
.aep reader/writer, in idiomatic TypeScript. It behaves exactly like py-aep
at upstream commit 2cef9f4 (see UPSTREAM_COMMIT): the same values, the same
errors, byte-identical saves.
This is a proof-of-concept project — use with caution.
The API uses After Effects' own scripting names in camelCase (comp.frameRate,
layer.transform.anchorPoint, layer.threeDLayer, group.property("ADBE
Position")), and JS conventions: settings are Map-shaped (get/set/has/
size), groups have length, console.log(layer) prints a readable summary.
Files keep their py-aep twins' paths, so an upstream change maps to the TS file
it touches. Python semantics the behaviour depends on (rounding, floor division,
struct, surrogateescape, …) live in src/pyrt/. Rules: RULEBOOK.md.
Status
Against upstream at the pinned commit (CI runs all of this, including the py-aep differential, on every push):
- Test suite: all 15,431 upstream tests translated to
bun:test; 15,369 pass, 62 skip on missing system fonts / OCIO configs / AE preferences — identical to CPython 3.12. (Runtime skips report as passes in bun's tally.) - Differential check against py-aep on all 644 upstream sample projects
(
tools/diff/): every public attribute of the parsed project is identical, and 25,427 deterministic edits through public setters give the same outcome and byte-identical saved files. - Parse → save is byte-identical on all 644 samples, in Node, Bun, a browser and workerd.
tscreports 0 errors withstricton everywhere (library, tests, tools). The library holds 16anyand 54 type assertions, most of them where py-aep itself uses an uncheckedcast(); the gate fails if either count grows. 26TODO(port)markers name the remaining known gaps (fontkit/harfbuzzjs vs fontTools/uharfbuzz text metrics, argparse help text in the CLIs, integers beyond 2^53, no LZMA decoder).
Install
bun add @bevyl-ai/ts-aepimport { parse } from "@bevyl-ai/ts-aep";
const app = parse("project.aep");
for (const comp of app.project.compositions) console.log(comp.name, comp.duration);
app.project.save("copy.aep");Use
The library runs unchanged in Node 22.3+, Bun, browsers and Cloudflare Workers.
Node and Bun load fontkit and yaml (text metrics, OCIO configs) on first use;
bundlers and Workers resolve the browser / workerd export, which imports them
up front.
parse takes a path or the file's bytes; project.save(path) takes a path. All
host access goes through src/pyrt/host.ts: Node and Bun use their own
node:fs, node:path and node:os (via process.getBuiltinModule); hosts
without them get an in-memory file system and POSIX paths.
A project decodes lazily: comp.layers builds each layer from its own
settings, and a layer's properties (Transform, Effects, Masks, Text, ...) are
decoded the first time one is read. A script that reads layer attributes only
(names, times, labels, switches, sources) never builds a property. Reading an
attribute a layer does not have (source on a camera) looks it up among its
properties, which builds them; test the layer's type instead.
Beyond py-aep's API: @bevyl-ai/ts-aep/svg exports py-aep's public svg
module (readSvg, buildShapeLayerContents, parseTransform);
project.addEffectDefinitions(efdg) imports effect definitions After Effects
saved in another project, so addProperty can add those effects;
FontObject.technology and designVector are writable (a CFF font needs
CT_TYPE1_FONT) and TextDocument.fonts lists every font a document
registers; TextLayer.continuouslyRasterize sets the collapse switch AE
scripting refuses on text (effects on unrasterized text ask for missing
footage); Property.arbitraryData reads and writes the effect data AE keeps
beside a parameter (Hue/Saturation's masters live there, not in their
sliders); FeatherPoint.of builds a mask feather point; Shape.bounds is the
box a shape's points are stored in.
COS reals that {:.10g} would write with an exponent are written fixed-point,
which After Effects requires.
Types
Children are typed by match name: group.property("ADBE Position") is a
Property<number[]> and layer.property("ADBE Transform Group") a group. The
children of the built-in groups (Transform, Text, camera / light / material
options, masks, shape fills, strokes and transforms) are also typed by their
attribute name, as in py-aep:
const layer = comp.layers[0];
const position: number[] = layer.transform.position.value;
const text = comp.textLayers[0].text?.sourceText.value.text;A child whose attribute name depends on the layer (anchorPoint on an AV
layer is a camera's pointOfInterest; rotation becomes zRotation on a 3D
layer) is reached by match name instead. Other built-in children resolve by
attribute name at runtime, untyped; third-party effect parameters are reached
with group.property("Display Name").
Node / Bun
import * as aep from "@bevyl-ai/ts-aep";
const app = aep.parse("project.aep");
app.project.save("copy.aep");Errors
Everything ts-aep throws extends AepError, as one of four kinds:
| Class | Thrown when |
| ------------------- | --------------------------------------------------------------- |
| InvalidValueError | a value After Effects would refuse, or bytes that do not decode |
| NotFoundError | no item, property, key or index by that name or position |
| UnsupportedError | the file, layer or property type does not support the operation |
| FileSystemError | a file cannot be read or written |
import { AepError, FileSystemError, parse } from "@bevyl-ai/ts-aep";
try {
parse("missing.aep");
} catch (e) {
if (e instanceof FileSystemError) console.log("cannot read it");
else if (e instanceof AepError) console.log(e.name, e.message);
else throw e;
}Bad arguments throw JS's own TypeError.
Browsers and Workers
Build a bundle, then move bytes in and out of memfs around the usual calls:
bun build src/py_aep/index.ts --target=browser --outdir=dist/browserimport * as py_aep from "./dist/browser/index.js";
py_aep.memfs.write("/in.aep", new Uint8Array(await (await fetch(url)).arrayBuffer()));
py_aep.parse("/in.aep").project.save("/out.aep");
const saved: Uint8Array = py_aep.memfs.read("/out.aep");memfs also has exists, list, remove and clear. Footage, fonts, ICC
profiles or OCIO configs a project refers to are only found if you write them
into memfs at the paths the project expects. useMemoryFs() forces the
in-memory backend where node:fs exists too; fsBackend() reports which one
is active.
Host caveats:
Node must be 22.3+ (or 20.16+): older Node has no
process.getBuiltinModule, so the library silently falls back to the in-memory fs and real paths are not found.Text recomposition shapes with harfbuzz (wasm). In a browser, serve
node_modules/harfbuzzjs/hb.wasmnext to the bundle'sindex.js. Where it cannot load, the library still loads and only shaping raises (the console shows emscripten's abort message once).Cloudflare Workers refuse to compile wasm from fetched bytes, so shaping is unavailable there; everything else works. With the
nodejs_compatflag,process.getBuiltinModule("node:fs")returns Workers' own virtual fs (only/tmpis writable, per request); calluseMemoryFs()to usememfsinstead. Parse and save are synchronous and CPU-bound: large projects can hit the Workers CPU-time limit.Without a host,
platformis"browser", so a new project's default platform is macOS (as on any non-Windows host),tmpdir()is/tmp,homedir()is/and the environment is empty.
Smoke checks: bun tools/browser-smoke/run.ts [N] runs the bundle with
process hidden (memory fs, POSIX paths) over N samples and checks saves are
byte-identical; tools/browser-smoke/index.html does the same in a real
browser (instructions in the file); tools/browser-smoke/workers-run.ts runs
the same check inside workerd through miniflare (see its header).
Development
bun install
tools/fetch-samples.sh # upstream checkout + LFS samples (~240 MB)
bun testLicense
MIT. Derived from py-aep, Copyright (c) 2023 Fortiche production; see LICENSE.
Releasing
Releases publish from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored anywhere:
- One-time, on npmjs.com: package
@bevyl-ai/ts-aep→ Settings → Trusted publishing → GitHub Actions, ownerbevyl-ai, repositoryts-aep, workflowrelease.yml. If npm requires the package to exist first, do the very first publish by hand (npm login, thenbun publish --access public) and add the trusted publisher afterwards. - Bump
versioninpackage.json, commit, thengit tag v<version> && git push --tags..github/workflows/release.ymlruns the tests and the packed-tarball smoke (tools/pack_smoke.sh), thennpm publishwith provenance.
