From eb1af417da6992ad076370e3b4d4b02519259414 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:57:00 -0400 Subject: [PATCH 01/25] chore(deps): npm audit fix Clears 8 advisories (5 high) reaching the tree through @modelcontextprotocol/sdk's transitive dependencies - hono, @hono/node-server, ajv, fast-uri, body-parser. SDK moves 1.0 -> 1.30. Semver-compatible only. Build is clean and the tool count is unchanged at 67 before the shape work lands. Co-Authored-By: Claude Opus 5 --- package-lock.json | 236 ++++++++++++++++++++++++++++++---------------- 1 file changed, 156 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 071f6f6..43f39ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -488,24 +488,24 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", - "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -513,14 +513,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -578,9 +579,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -617,20 +618,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -640,6 +641,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -725,9 +739,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -875,9 +889,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1008,10 +1022,14 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, "engines": { "node": ">= 16" }, @@ -1029,9 +1047,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -1181,9 +1199,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1193,11 +1211,10 @@ } }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1223,9 +1240,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1244,6 +1261,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1331,12 +1357,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -1383,12 +1413,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/object-assign": { @@ -1461,9 +1511,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1480,9 +1530,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1490,15 +1540,20 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1508,12 +1563,16 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -1678,14 +1737,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1697,13 +1756,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1820,17 +1879,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { From 5def3bc568aa80f430160953d91dd2f76f0b2c31 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:57:14 -0400 Subject: [PATCH 02/25] feat(shapes): bezier paths, shape operators, and matchName lookups Adds the shape-layer capability the server was missing. Previously it could only create parametric Rect/Ellipse/Star primitives - never an actual editable path, and none of the shape operators. New tools (67 -> 71): create_path editable bezier path; creates a shape layer or adds to an existing one get_path reads vertices/tangents/closed back, including every keyframe when the path is animated set_path_keyframes animates a path add_shape_operator all 12 operators with friendly property names Notes on three traps this encodes, all verified against AE 26.5: - Path keyframes must be written one at a time with setValueAtTime(). The bulk setValuesAtTimes() does not work on Shape-valued properties, only on numeric ones. - addProperty() on a PropertyGroup invalidates references already held to that group's other children. Adding a Fill/Stroke after creating a path group makes the earlier handle throw "ReferenceError: Object is invalid" on something as harmless as .name, so the path name is read into a plain variable before any siblings are added. - Pucker & Bloat is "ADBE Vector Filter - PB". The plausible-looking "ADBE Vector Filter - PuckerBloat" is not a valid match name and hard-crashes After Effects with no exception and no dialog. Operator names are validated in TypeScript so an invalid one can never reach AE. Gradient tools document that color stops cannot be set by scripting: After Effects exposes ADBE Vector Grad Colors as NO_VALUE. Also replaces every English display-name property lookup in add_shape_layer with match names - property("Size") returns null on a non-English After Effects and the script then dies - and exposes rectangle roundness, which was already in the tree but unused. Co-Authored-By: Claude Opus 5 --- .../generators/layerGenerators.ts | 42 +- .../generators/shapeGenerators.ts | 518 ++++++++++++++++++ src/ae-integration/scriptGenerator.ts | 11 + src/stdio-server.ts | 117 +++- 4 files changed, 670 insertions(+), 18 deletions(-) create mode 100644 src/ae-integration/generators/shapeGenerators.ts diff --git a/src/ae-integration/generators/layerGenerators.ts b/src/ae-integration/generators/layerGenerators.ts index aa7e7e9..7ecadb2 100644 --- a/src/ae-integration/generators/layerGenerators.ts +++ b/src/ae-integration/generators/layerGenerators.ts @@ -219,6 +219,7 @@ export function generateAddShapeLayer(params: { points?: number; innerRadius?: number; outerRadius?: number; + roundness?: number; }): string { let script = ''; script += generateProjectCheck(); @@ -230,10 +231,13 @@ export function generateAddShapeLayer(params: { script += 'layer.name = "' + escapeString(params.name) + '";\n'; } - // Add shape content - script += 'var contents = layer.property("Contents");\n'; + // Add shape content. + // Everything below addresses properties by matchName rather than English + // display name. property("Size") returns null on a non-English After Effects + // and the script then dies on a null dereference. + script += 'var contents = layer.property("ADBE Root Vectors Group");\n'; script += 'var shapeGroup = contents.addProperty("ADBE Vector Group");\n'; - script += 'var shapeContents = shapeGroup.property("Contents");\n'; + script += 'var shapeContents = shapeGroup.property("ADBE Vectors Group");\n'; const shape = params.shape || 'rectangle'; const sizeW = params.size?.width || 200; @@ -241,44 +245,48 @@ export function generateAddShapeLayer(params: { if (shape === 'rectangle') { script += 'var rect = shapeContents.addProperty("ADBE Vector Shape - Rect");\n'; - script += 'rect.property("Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + script += 'rect.property("ADBE Vector Rect Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + if (params.roundness !== undefined) { + script += 'rect.property("ADBE Vector Rect Roundness").setValue(' + params.roundness + ');\n'; + } } else if (shape === 'ellipse') { script += 'var ellipse = shapeContents.addProperty("ADBE Vector Shape - Ellipse");\n'; - script += 'ellipse.property("Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + script += 'ellipse.property("ADBE Vector Ellipse Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; } else if (shape === 'polygon') { script += 'var poly = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'poly.property("Type").setValue(1);\n'; // polygon - script += 'poly.property("Points").setValue(' + (params.points || 6) + ');\n'; - script += 'poly.property("Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; + script += 'poly.property("ADBE Vector Star Type").setValue(1);\n'; // polygon + script += 'poly.property("ADBE Vector Star Points").setValue(' + (params.points || 6) + ');\n'; + script += 'poly.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; } else if (shape === 'star') { script += 'var star = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'star.property("Type").setValue(2);\n'; // star - script += 'star.property("Points").setValue(' + (params.points || 5) + ');\n'; - script += 'star.property("Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; - script += 'star.property("Inner Radius").setValue(' + (params.innerRadius || 50) + ');\n'; + script += 'star.property("ADBE Vector Star Type").setValue(2);\n'; // star + script += 'star.property("ADBE Vector Star Points").setValue(' + (params.points || 5) + ');\n'; + script += 'star.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; + script += 'star.property("ADBE Vector Star Inner Radius").setValue(' + (params.innerRadius || 50) + ');\n'; } // Add fill if (params.fillColor) { script += 'var fill = shapeContents.addProperty("ADBE Vector Graphic - Fill");\n'; - script += 'fill.property("Color").setValue(' + colorToES3(params.fillColor) + ');\n'; + script += 'fill.property("ADBE Vector Fill Color").setValue(' + colorToES3(params.fillColor) + ');\n'; } // Add stroke if (params.strokeColor) { script += 'var stroke = shapeContents.addProperty("ADBE Vector Graphic - Stroke");\n'; - script += 'stroke.property("Color").setValue(' + colorToES3(params.strokeColor) + ');\n'; + script += 'stroke.property("ADBE Vector Stroke Color").setValue(' + colorToES3(params.strokeColor) + ');\n'; if (params.strokeWidth !== undefined) { - script += 'stroke.property("Stroke Width").setValue(' + params.strokeWidth + ');\n'; + script += 'stroke.property("ADBE Vector Stroke Width").setValue(' + params.strokeWidth + ');\n'; } } // Position + script += 'var xform = layer.property("ADBE Transform Group");\n'; if (params.position) { - script += 'layer.property("Position").setValue(' + positionToES3(params.position) + ');\n'; + script += 'xform.property("ADBE Position").setValue(' + positionToES3(params.position) + ');\n'; } else { // Center in comp - script += 'layer.property("Position").setValue([comp.width/2, comp.height/2]);\n'; + script += 'xform.property("ADBE Position").setValue([comp.width/2, comp.height/2]);\n'; } script += generateResultObject({ diff --git a/src/ae-integration/generators/shapeGenerators.ts b/src/ae-integration/generators/shapeGenerators.ts new file mode 100644 index 0000000..ea7f238 --- /dev/null +++ b/src/ae-integration/generators/shapeGenerators.ts @@ -0,0 +1,518 @@ +/** + * Shape Layer Generators + * + * Bezier path creation / reading / animation, plus shape operators + * (Trim Paths, Repeater, etc). + * + * IMPORTANT: every property lookup in here uses matchNames, never English + * display names. `rect.property("Size")` returns null on a non-English After + * Effects; `rect.property("ADBE Vector Rect Size")` always works. + * + * Match names verified empirically against After Effects 26.5 and cross-checked + * against docsforadobe/after-effects-scripting-guide. + */ + +import { + escapeString, + arrayToES3, + colorToES3, + positionToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * Shape layer structural match names. + */ +export const SHAPE_MATCH_NAMES = { + rootContents: 'ADBE Root Vectors Group', + group: 'ADBE Vector Group', + groupContents: 'ADBE Vectors Group', + pathGroup: 'ADBE Vector Shape - Group', + path: 'ADBE Vector Shape', + fill: 'ADBE Vector Graphic - Fill', + fillColor: 'ADBE Vector Fill Color', + stroke: 'ADBE Vector Graphic - Stroke', + strokeColor: 'ADBE Vector Stroke Color', + strokeWidth: 'ADBE Vector Stroke Width' +}; + +/** + * Shape operators, all verified against AE 26.5. + * + * NOTE: Pucker & Bloat is "ADBE Vector Filter - PB". The plausible-looking + * "ADBE Vector Filter - PuckerBloat" is NOT a valid match name and passing it + * to addProperty() hard-crashes After Effects with no exception and no dialog. + */ +export const SHAPE_OPERATORS: Record = { + trimPaths: 'ADBE Vector Filter - Trim', + repeater: 'ADBE Vector Filter - Repeater', + roundCorners: 'ADBE Vector Filter - RC', + zigZag: 'ADBE Vector Filter - Zigzag', + twist: 'ADBE Vector Filter - Twist', + puckerBloat: 'ADBE Vector Filter - PB', + offsetPaths: 'ADBE Vector Filter - Offset', + wigglePaths: 'ADBE Vector Filter - Roughen', + wiggleTransform: 'ADBE Vector Filter - Wiggler', + mergePaths: 'ADBE Vector Filter - Merge', + gradientFill: 'ADBE Vector Graphic - G-Fill', + gradientStroke: 'ADBE Vector Graphic - G-Stroke' +}; + +/** + * Per-operator property match names, so callers can set values without + * knowing the raw ADBE strings. + */ +export const SHAPE_OPERATOR_PROPERTIES: Record> = { + trimPaths: { + start: 'ADBE Vector Trim Start', + end: 'ADBE Vector Trim End', + offset: 'ADBE Vector Trim Offset', + trimMultipleShapes: 'ADBE Vector Trim Type' + }, + repeater: { + copies: 'ADBE Vector Repeater Copies', + offset: 'ADBE Vector Repeater Offset', + composite: 'ADBE Vector Repeater Order' + }, + roundCorners: { radius: 'ADBE Vector RoundCorner Radius' }, + zigZag: { + size: 'ADBE Vector Zigzag Size', + ridgesPerSegment: 'ADBE Vector Zigzag Detail', + points: 'ADBE Vector Zigzag Points' + }, + twist: { angle: 'ADBE Vector Twist Angle', center: 'ADBE Vector Twist Center' }, + puckerBloat: { amount: 'ADBE Vector PuckerBloat Amount' }, + offsetPaths: { + amount: 'ADBE Vector Offset Amount', + lineJoin: 'ADBE Vector Offset Line Join', + miterLimit: 'ADBE Vector Offset Miter Limit', + copies: 'ADBE Vector Offset Copies', + copyOffset: 'ADBE Vector Offset Copy Offset' + }, + wigglePaths: { + size: 'ADBE Vector Roughen Size', + detail: 'ADBE Vector Roughen Detail', + points: 'ADBE Vector Roughen Points', + wigglesPerSecond: 'ADBE Vector Temporal Freq', + correlation: 'ADBE Vector Correlation', + temporalPhase: 'ADBE Vector Temporal Phase', + spatialPhase: 'ADBE Vector Spatial Phase', + randomSeed: 'ADBE Vector Random Seed' + }, + wiggleTransform: { + wigglesPerSecond: 'ADBE Vector Xform Temporal Freq', + correlation: 'ADBE Vector Correlation', + temporalPhase: 'ADBE Vector Temporal Phase', + spatialPhase: 'ADBE Vector Spatial Phase', + randomSeed: 'ADBE Vector Random Seed' + }, + mergePaths: { mode: 'ADBE Vector Merge Type' }, + gradientFill: { + type: 'ADBE Vector Grad Type', + startPoint: 'ADBE Vector Grad Start Pt', + endPoint: 'ADBE Vector Grad End Pt', + highlightLength: 'ADBE Vector Grad HiLite Length', + highlightAngle: 'ADBE Vector Grad HiLite Angle', + opacity: 'ADBE Vector Fill Opacity' + }, + gradientStroke: { + type: 'ADBE Vector Grad Type', + startPoint: 'ADBE Vector Grad Start Pt', + endPoint: 'ADBE Vector Grad End Pt', + opacity: 'ADBE Vector Stroke Opacity', + strokeWidth: 'ADBE Vector Stroke Width' + } +}; + +/** + * Validate a vertex list and its tangents at generate time, so bad input fails + * here with a clear message instead of somewhere inside After Effects. + */ +function validatePathPoints( + vertices: number[][], + inTangents?: number[][], + outTangents?: number[][] +): void { + if (!vertices || !Array.isArray(vertices) || vertices.length < 2) { + throw new Error('vertices must be an array of at least 2 [x, y] points'); + } + for (let i = 0; i < vertices.length; i++) { + const v = vertices[i]; + if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== 'number' || typeof v[1] !== 'number') { + throw new Error('vertices[' + i + '] must be a numeric [x, y] pair'); + } + } + const check = (t: number[][] | undefined, label: string) => { + if (t === undefined) return; + if (!Array.isArray(t) || t.length !== vertices.length) { + throw new Error(label + ' must have exactly one [x, y] pair per vertex (' + vertices.length + ')'); + } + for (let i = 0; i < t.length; i++) { + const p = t[i]; + if (!Array.isArray(p) || p.length !== 2 || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error(label + '[' + i + '] must be a numeric [x, y] pair'); + } + } + }; + check(inTangents, 'inTangents'); + check(outTangents, 'outTangents'); +} + +/** Zero tangents (corner points) for a vertex list. */ +function zeroTangents(count: number): number[][] { + const out: number[][] = []; + for (let i = 0; i < count; i++) { + out.push([0, 0]); + } + return out; +} + +/** + * Emit ES3 that builds a Shape object into the variable `varName`. + * + * Tangents are RELATIVE to their own vertex, not absolute comp coordinates. + * This is the single most common source of mangled curves. + */ +function emitShapeObject( + varName: string, + vertices: number[][], + inTangents: number[][], + outTangents: number[][], + closed: boolean +): string { + let s = ''; + s += 'var ' + varName + ' = new Shape();\n'; + s += varName + '.vertices = ' + arrayToES3(vertices) + ';\n'; + s += varName + '.inTangents = ' + arrayToES3(inTangents) + ';\n'; + s += varName + '.outTangents = ' + arrayToES3(outTangents) + ';\n'; + s += varName + '.closed = ' + (closed ? 'true' : 'false') + ';\n'; + return s; +} + +/** + * Emit an ES3 helper that finds a path property inside a shape layer by + * walking Contents recursively. Defines findPathProp(layer, pathName). + * + * Passing pathName === null returns the first path found. + */ +function emitFindPathHelper(): string { + let s = ''; + s += 'function isGroup(p) {\n'; + s += ' return p.propertyType === PropertyType.INDEXED_GROUP || p.propertyType === PropertyType.NAMED_GROUP;\n'; + s += '}\n'; + s += 'function searchPath(group, pathName) {\n'; + s += ' for (var i = 1; i <= group.numProperties; i++) {\n'; + s += ' var child = group.property(i);\n'; + s += ' if (child.matchName === "' + SHAPE_MATCH_NAMES.pathGroup + '") {\n'; + s += ' if (pathName === null || child.name === pathName) {\n'; + s += ' return child.property("' + SHAPE_MATCH_NAMES.path + '");\n'; + s += ' }\n'; + s += ' }\n'; + s += ' if (isGroup(child)) {\n'; + s += ' var found = searchPath(child, pathName);\n'; + s += ' if (found !== null) { return found; }\n'; + s += ' }\n'; + s += ' }\n'; + s += ' return null;\n'; + s += '}\n'; + s += 'function findPathProp(layer, pathName) {\n'; + s += ' var root = layer.property("' + SHAPE_MATCH_NAMES.rootContents + '");\n'; + s += ' if (!root) { throw new Error("Layer is not a shape layer: " + layer.name); }\n'; + s += ' var p = searchPath(root, pathName);\n'; + s += ' if (p === null) {\n'; + s += ' throw new Error(pathName === null ? "No bezier path found on layer: " + layer.name : "Path not found: " + pathName);\n'; + s += ' }\n'; + s += ' return p;\n'; + s += '}\n'; + return s; +} + +/** + * Emit ES3 resolving `targetGroupContents` — the Contents of the shape group + * we should add things to. Creates a group if the layer has none. + */ +function emitGroupContents(groupIndex?: number): string { + let s = ''; + s += 'var rootContents = layer.property("' + SHAPE_MATCH_NAMES.rootContents + '");\n'; + s += 'if (!rootContents) { throw new Error("Layer is not a shape layer: " + layer.name); }\n'; + s += 'var vectorGroup = null;\n'; + if (groupIndex !== undefined) { + s += 'if (' + groupIndex + ' > rootContents.numProperties) {\n'; + s += ' throw new Error("Group index ' + groupIndex + ' out of range; layer has " + rootContents.numProperties + " group(s)");\n'; + s += '}\n'; + s += 'vectorGroup = rootContents.property(' + groupIndex + ');\n'; + } else { + s += 'for (var gi = 1; gi <= rootContents.numProperties; gi++) {\n'; + s += ' if (rootContents.property(gi).matchName === "' + SHAPE_MATCH_NAMES.group + '") {\n'; + s += ' vectorGroup = rootContents.property(gi);\n'; + s += ' break;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (vectorGroup === null) {\n'; + s += ' vectorGroup = rootContents.addProperty("' + SHAPE_MATCH_NAMES.group + '");\n'; + s += '}\n'; + } + s += 'var targetGroupContents = vectorGroup.property("' + SHAPE_MATCH_NAMES.groupContents + '");\n'; + return s; +} + +/** + * Create a bezier path. + * + * With no layerIndex/layerName a new shape layer is created; otherwise the path + * is added to the existing shape layer. + */ +export function generateCreatePath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + name?: string; + pathName?: string; + groupIndex?: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + fillColor?: { r: number; g: number; b: number }; + strokeColor?: { r: number; g: number; b: number }; + strokeWidth?: number; + position?: { x: number; y: number }; +}): string { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + + const inTangents = params.inTangents || zeroTangents(params.vertices.length); + const outTangents = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + const usingExistingLayer = params.layerIndex !== undefined || params.layerName !== undefined; + if (usingExistingLayer) { + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + } else { + script += 'var layer = comp.layers.addShape();\n'; + if (params.name) { + script += 'layer.name = "' + escapeString(params.name) + '";\n'; + } + } + + script += emitGroupContents(params.groupIndex); + + script += 'var pathGroup = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.pathGroup + '");\n'; + if (params.pathName) { + script += 'pathGroup.name = "' + escapeString(params.pathName) + '";\n'; + } + script += 'var pathProp = pathGroup.property("' + SHAPE_MATCH_NAMES.path + '");\n'; + script += emitShapeObject('shapeObj', params.vertices, inTangents, outTangents, closed); + script += 'pathProp.setValue(shapeObj);\n'; + // Read the name into a plain string NOW. Calling addProperty() on a group + // invalidates references already held to that group's other children, so + // touching pathGroup after the fill/stroke below throws + // "ReferenceError: Object is invalid". + script += 'var createdPathName = pathGroup.name;\n'; + + if (params.fillColor) { + script += 'var fill = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.fill + '");\n'; + script += 'fill.property("' + SHAPE_MATCH_NAMES.fillColor + '").setValue(' + colorToES3(params.fillColor) + ');\n'; + } + if (params.strokeColor) { + script += 'var stroke = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.stroke + '");\n'; + script += 'stroke.property("' + SHAPE_MATCH_NAMES.strokeColor + '").setValue(' + colorToES3(params.strokeColor) + ');\n'; + if (params.strokeWidth !== undefined) { + script += 'stroke.property("' + SHAPE_MATCH_NAMES.strokeWidth + '").setValue(' + params.strokeWidth + ');\n'; + } + } + + if (params.position) { + script += 'layer.property("ADBE Transform Group").property("ADBE Position").setValue(' + positionToES3(params.position) + ');\n'; + } else if (!usingExistingLayer) { + script += 'layer.property("ADBE Transform Group").property("ADBE Position").setValue([comp.width / 2, comp.height / 2]);\n'; + } + + script += generateResultObject({ + layerIndex: 'layer.index', + layerName: 'layer.name', + pathName: 'createdPathName', + vertexCount: String(params.vertices.length), + closed: closed ? 'true' : 'false' + }); + + return wrapInUndoGroup(script, 'Create Path'); +} + +/** + * Read a bezier path back, including its keyframes if it is animated. + */ +export function generateGetPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + pathName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitFindPathHelper(); + + const pathNameArg = params.pathName ? '"' + escapeString(params.pathName) + '"' : 'null'; + script += 'var pathProp = findPathProp(layer, ' + pathNameArg + ');\n'; + script += 'var current = pathProp.value;\n'; + script += 'var keys = [];\n'; + script += 'for (var k = 1; k <= pathProp.numKeys; k++) {\n'; + script += ' var kv = pathProp.keyValue(k);\n'; + script += ' keys.push({\n'; + script += ' time: pathProp.keyTime(k),\n'; + script += ' vertices: kv.vertices,\n'; + script += ' inTangents: kv.inTangents,\n'; + script += ' outTangents: kv.outTangents,\n'; + script += ' closed: kv.closed\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + pathName: 'pathProp.parentProperty.name', + vertices: 'current.vertices', + inTangents: 'current.inTangents', + outTangents: 'current.outTangents', + closed: 'current.closed', + isAnimated: 'pathProp.numKeys > 0', + numKeys: 'pathProp.numKeys', + keyframes: 'keys' + }); + + return script; +} + +/** + * Animate a bezier path. + * + * Shape-valued properties must be keyed ONE AT A TIME with setValueAtTime(). + * The bulk setter setValuesAtTimes() does not work on them — it only works for + * numeric properties. Getting this wrong fails in confusing ways. + */ +export function generateSetPathKeyframes(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + pathName?: string; + keyframes: Array<{ + time: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + }>; +}): string { + if (!params.keyframes || !Array.isArray(params.keyframes) || params.keyframes.length === 0) { + throw new Error('keyframes must be a non-empty array'); + } + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + if (typeof kf.time !== 'number') { + throw new Error('keyframes[' + i + '].time must be a number'); + } + validatePathPoints(kf.vertices, kf.inTangents, kf.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitFindPathHelper(); + + const pathNameArg = params.pathName ? '"' + escapeString(params.pathName) + '"' : 'null'; + script += 'var pathProp = findPathProp(layer, ' + pathNameArg + ');\n'; + + // One setValueAtTime() per key. See the note above. + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + const inT = kf.inTangents || zeroTangents(kf.vertices.length); + const outT = kf.outTangents || zeroTangents(kf.vertices.length); + const cl = kf.closed !== undefined ? kf.closed : true; + script += emitShapeObject('kfShape' + i, kf.vertices, inT, outT, cl); + script += 'pathProp.setValueAtTime(' + kf.time + ', kfShape' + i + ');\n'; + } + + script += generateResultObject({ + layerName: 'layer.name', + pathName: 'pathProp.parentProperty.name', + keysWritten: String(params.keyframes.length), + numKeys: 'pathProp.numKeys' + }); + + return wrapInUndoGroup(script, 'Set Path Keyframes'); +} + +/** + * Add a shape operator (Trim Paths, Repeater, etc) to a shape layer. + * + * Operators are appended to the end of the group's Contents, which is what AE + * does when you add one from the UI: an operator affects the paths above it in + * the same group. + */ +export function generateAddShapeOperator(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + operator: string; + groupIndex?: number; + name?: string; + properties?: Record; +}): string { + const matchName = SHAPE_OPERATORS[params.operator]; + if (!matchName) { + throw new Error( + 'Unknown shape operator: "' + params.operator + '". Valid operators: ' + + Object.keys(SHAPE_OPERATORS).join(', ') + ); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitGroupContents(params.groupIndex); + + script += 'var op = targetGroupContents.addProperty("' + matchName + '");\n'; + if (params.name) { + script += 'op.name = "' + escapeString(params.name) + '";\n'; + } + + if (params.properties) { + const propMap = SHAPE_OPERATOR_PROPERTIES[params.operator] || {}; + for (const key in params.properties) { + if (!params.properties.hasOwnProperty(key)) continue; + const propMatchName = propMap[key]; + if (!propMatchName) { + throw new Error( + 'Unknown property "' + key + '" for operator "' + params.operator + '". Valid: ' + + (Object.keys(propMap).join(', ') || '(none settable)') + ); + } + const value = params.properties[key]; + const valueES3 = Array.isArray(value) ? arrayToES3(value) : String(value); + script += 'op.property("' + propMatchName + '").setValue(' + valueES3 + ');\n'; + } + } + + script += generateResultObject({ + layerName: 'layer.name', + operator: '"' + escapeString(params.operator) + '"', + matchName: 'op.matchName', + name: 'op.name' + }); + + return wrapInUndoGroup(script, 'Add Shape Operator'); +} diff --git a/src/ae-integration/scriptGenerator.ts b/src/ae-integration/scriptGenerator.ts index 4795c2c..b121987 100644 --- a/src/ae-integration/scriptGenerator.ts +++ b/src/ae-integration/scriptGenerator.ts @@ -119,6 +119,17 @@ export { generateNavigateMarkers } from './generators/markerGenerators.js'; +// Shape generators (bezier paths + shape operators) +export { + generateCreatePath, + generateGetPath, + generateSetPathKeyframes, + generateAddShapeOperator, + SHAPE_OPERATORS, + SHAPE_OPERATOR_PROPERTIES, + SHAPE_MATCH_NAMES +} from './generators/shapeGenerators.js'; + // Helpers (for direct use if needed) export { escapeString, diff --git a/src/stdio-server.ts b/src/stdio-server.ts index b02596b..a85b83f 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -316,11 +316,126 @@ const TOOLS = [ strokeWidth: { type: 'number' }, points: { type: 'number', description: 'Number of points for polygon/star' }, innerRadius: { type: 'number', description: 'Inner radius for star' }, - outerRadius: { type: 'number', description: 'Outer radius for polygon/star' } + outerRadius: { type: 'number', description: 'Outer radius for polygon/star' }, + roundness: { type: 'number', description: 'Corner roundness (rectangle only)' } } }, generator: generators.generateAddShapeLayer }, + { + name: 'create_path', + description: 'Create an editable bezier path on a shape layer. Creates a new shape layer unless layerIndex/layerName is given. Tangents are RELATIVE to their own vertex, not absolute coordinates.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number', description: 'Existing shape layer to add the path to' }, + layerName: { type: 'string', description: 'Existing shape layer to add the path to' }, + name: { type: 'string', description: 'Name for the new layer (when creating one)' }, + pathName: { type: 'string', description: 'Name for the path group' }, + groupIndex: { type: 'number', description: 'Which shape group to add to (default: first, created if none)' }, + vertices: { + type: 'array', + description: 'Array of [x, y] points in layer space. At least 2 required.', + items: { type: 'array', items: { type: 'number' } } + }, + inTangents: { + type: 'array', + description: 'Optional [x, y] per vertex, RELATIVE to that vertex. Defaults to corner points.', + items: { type: 'array', items: { type: 'number' } } + }, + outTangents: { + type: 'array', + description: 'Optional [x, y] per vertex, RELATIVE to that vertex. Defaults to corner points.', + items: { type: 'array', items: { type: 'number' } } + }, + closed: { type: 'boolean', description: 'Close the path (default true)' }, + fillColor: { type: 'object', description: 'Fill color (0-1 range)' }, + strokeColor: { type: 'object', description: 'Stroke color (0-1 range)' }, + strokeWidth: { type: 'number' }, + position: { type: 'object', description: 'Layer position {x, y}' } + }, + required: ['vertices'] + }, + generator: generators.generateCreatePath + }, + { + name: 'get_path', + description: 'Read a bezier path back from a shape layer, including all keyframes if it is animated. Returns vertices, inTangents, outTangents and closed.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + pathName: { type: 'string', description: 'Which path to read; omit for the first path found' } + } + }, + generator: generators.generateGetPath + }, + { + name: 'set_path_keyframes', + description: 'Animate a bezier path by writing shape keyframes. Each keyframe needs its own full vertex list; vertex counts may differ between keys but AE interpolates best when they match.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + pathName: { type: 'string', description: 'Which path to animate; omit for the first path found' }, + keyframes: { + type: 'array', + description: 'Array of { time, vertices, inTangents?, outTangents?, closed? }', + items: { + type: 'object', + properties: { + time: { type: 'number', description: 'Time in seconds' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['time', 'vertices'] + } + } + }, + required: ['keyframes'] + }, + generator: generators.generateSetPathKeyframes + }, + { + name: 'add_shape_operator', + description: 'Add a shape operator (Trim Paths, Repeater, Round Corners, Zig Zag, Twist, Pucker & Bloat, Offset Paths, Wiggle Paths, Wiggle Transform, Merge Paths, Gradient Fill/Stroke) to a shape layer. NOTE: gradient COLOR STOPS cannot be set by scripting - After Effects exposes ADBE Vector Grad Colors as NO_VALUE - so gradients will render with default colors.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + operator: { + type: 'string', + enum: [ + 'trimPaths', 'repeater', 'roundCorners', 'zigZag', 'twist', 'puckerBloat', + 'offsetPaths', 'wigglePaths', 'wiggleTransform', 'mergePaths', + 'gradientFill', 'gradientStroke' + ], + description: 'Which operator to add' + }, + groupIndex: { type: 'number', description: 'Which shape group to add to (default: first)' }, + name: { type: 'string', description: 'Rename the operator' }, + properties: { + type: 'object', + description: 'Operator properties by friendly name, e.g. { start: 0, end: 50 } for trimPaths or { copies: 6 } for repeater' + } + }, + required: ['operator'] + }, + generator: generators.generateAddShapeOperator + }, { name: 'add_null_layer', description: 'Add a null object layer', From 4f6e14122803998c0ba8330ac05b9f8f29f9da77 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:44:59 -0400 Subject: [PATCH 03/25] feat(tools): expose 10 generators that were written but never registered The server exported 81 script generators but only wired 71 of them to MCP tools. These ten were fully implemented and simply unreachable: list_project_items set_active_composition set_proxy remove_proxy get_current_time set_current_time snap_to_marker get_nearest_marker navigate_markers batch_set_expressions No new ExtendScript - this is schema and registration only. Tool count 71 -> 81, orphaned generators 10 -> 0. Co-Authored-By: Claude Opus 5 --- src/stdio-server.ts | 146 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/stdio-server.ts b/src/stdio-server.ts index a85b83f..737dc8d 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -436,6 +436,152 @@ const TOOLS = [ }, generator: generators.generateAddShapeOperator }, + { + name: 'list_project_items', + description: 'List every item in the project, optionally filtered by type', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', enum: ['composition', 'footage', 'folder'], description: 'Only return items of this type' } + } + }, + generator: generators.generateListProjectItems + }, + { + name: 'set_active_composition', + description: 'Open a composition in the viewer and make it the active item', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' } + } + }, + generator: generators.generateSetActiveComposition + }, + { + name: 'set_proxy', + description: 'Attach a proxy file to a project item', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'number' }, + itemName: { type: 'string' }, + proxyPath: { type: 'string', description: 'Absolute path to the proxy file' } + }, + required: ['proxyPath'] + }, + generator: generators.generateSetProxy + }, + { + name: 'remove_proxy', + description: 'Detach the proxy from a project item', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'number' }, + itemName: { type: 'string' } + } + }, + generator: generators.generateRemoveProxy + }, + { + name: 'get_current_time', + description: 'Get the current playhead time of a composition, in seconds and frames', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' } + } + }, + generator: generators.generateGetCurrentTime + }, + { + name: 'set_current_time', + description: 'Move the playhead of a composition. Give either time (seconds) or frame.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + time: { type: 'number', description: 'Time in seconds' }, + frame: { type: 'number', description: 'Frame number (used when time is omitted)' } + } + }, + generator: generators.generateSetCurrentTime + }, + { + name: 'snap_to_marker', + description: 'Move the playhead to a specific marker, on the composition or on a layer', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + markerIndex: { type: 'number', description: '1-based marker index' }, + layerIndex: { type: 'number', description: 'Use a layer marker instead of a comp marker' }, + layerName: { type: 'string', description: 'Use a layer marker instead of a comp marker' } + }, + required: ['markerIndex'] + }, + generator: generators.generateSnapToMarker + }, + { + name: 'get_nearest_marker', + description: 'Find the marker closest to a given time', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + time: { type: 'number', description: 'Time in seconds; defaults to the current playhead' }, + direction: { type: 'string', enum: ['previous', 'next', 'nearest'], description: 'Which way to look (default nearest)' } + } + }, + generator: generators.generateGetNearestMarker + }, + { + name: 'navigate_markers', + description: 'Jump the playhead to the first, last, previous or next composition marker', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + direction: { type: 'string', enum: ['first', 'last', 'previous', 'next'] } + }, + required: ['direction'] + }, + generator: generators.generateNavigateMarkers + }, + { + name: 'batch_set_expressions', + description: 'Set several expressions on one layer in a single operation', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + expressions: { + type: 'array', + description: 'Array of { property, expression }', + items: { + type: 'object', + properties: { + property: { type: 'string', description: 'Property name, e.g. "Position"' }, + expression: { type: 'string' } + }, + required: ['property', 'expression'] + } + } + }, + required: ['expressions'] + }, + generator: generators.generateBatchSetExpressions + }, { name: 'add_null_layer', description: 'Add a null object layer', From 5a5a57ff465c875292aafa71fd61faa102414c26 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:49:40 -0400 Subject: [PATCH 04/25] feat(render): add render queue tools The server had no render queue coverage at all. render_frame grabs a single still, so there was no way to actually deliver anything. add_to_render_queue queue a comp, with templates, output path and time span list_render_queue every item with status, span and output modules - poll this for progress list_render_templates the render settings and output module presets configured in this AE install, so renders reuse existing presets by name set_render_queue_item retarget output, templates, span, or whether an item is queued remove_from_render_queue drop one item or clear the queue control_render start / stop / pause / resume / showWindow queue_in_ame hand off to Adobe Media Encoder Two behaviours callers need to know, both documented on the tools: - RenderQueue.render() is synchronous. After Effects blocks until the whole queue finishes, so control_render "start" will usually exceed the bridge's command timeout on any real render. queue_in_ame returns immediately because AME renders out of process, which makes it the better choice for long jobs. - The output module decides the container. Passing a .mov path while the module is set to H.264 yields a .mp4, so the resolved path is returned rather than echoing what was asked for. RQItemStatus is mapped to readable strings; the raw enum crosses the bridge as an opaque number. Verified end to end against AE 26.5: queue, edit, inspect, pause, resume and clear. render() and queue_in_ame were deliberately not executed - both tie up the machine. Co-Authored-By: Claude Opus 5 --- .../generators/renderQueueGenerators.ts | 341 ++++++++++++++++++ src/ae-integration/scriptGenerator.ts | 11 + src/stdio-server.ts | 85 +++++ 3 files changed, 437 insertions(+) create mode 100644 src/ae-integration/generators/renderQueueGenerators.ts diff --git a/src/ae-integration/generators/renderQueueGenerators.ts b/src/ae-integration/generators/renderQueueGenerators.ts new file mode 100644 index 0000000..9f9988e --- /dev/null +++ b/src/ae-integration/generators/renderQueueGenerators.ts @@ -0,0 +1,341 @@ +/** + * Render Queue Generators + * + * The server previously had no render queue coverage at all - render_frame + * grabs a single still, but there was no way to actually deliver anything. + * + * Verified against the After Effects scripting guide (renderqueue/*). + */ + +import { + escapeString, + generateProjectCheck, + generateCompAccess, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * Emit an ES3 helper that maps RQItemStatus to a readable string. + * The enum values are opaque numbers over the bridge, so translate them here. + */ +function emitStatusHelper(): string { + let s = ''; + s += 'function statusName(st) {\n'; + s += ' if (st === RQItemStatus.WILL_CONTINUE) { return "willContinue"; }\n'; + s += ' if (st === RQItemStatus.NEEDS_OUTPUT) { return "needsOutput"; }\n'; + s += ' if (st === RQItemStatus.UNQUEUED) { return "unqueued"; }\n'; + s += ' if (st === RQItemStatus.QUEUED) { return "queued"; }\n'; + s += ' if (st === RQItemStatus.RENDERING) { return "rendering"; }\n'; + s += ' if (st === RQItemStatus.USER_STOPPED) { return "userStopped"; }\n'; + s += ' if (st === RQItemStatus.ERR_STOPPED) { return "errStopped"; }\n'; + s += ' if (st === RQItemStatus.DONE) { return "done"; }\n'; + s += ' return "unknown";\n'; + s += '}\n'; + return s; +} + +/** Emit ES3 resolving `rqItem` from a 1-based render queue index. */ +function emitRQItemAccess(itemIndex: number): string { + let s = ''; + s += 'var rq = app.project.renderQueue;\n'; + s += 'if (' + itemIndex + ' < 1 || ' + itemIndex + ' > rq.numItems) {\n'; + s += ' throw new Error("Render queue index ' + itemIndex + ' out of range; queue has " + rq.numItems + " item(s)");\n'; + s += '}\n'; + s += 'var rqItem = rq.item(' + itemIndex + ');\n'; + return s; +} + +/** + * Add a composition to the render queue. + */ +export function generateAddToRenderQueue(params: { + compId?: number; + compName?: string; + outputPath?: string; + renderSettingsTemplate?: string; + outputModuleTemplate?: string; + timeSpanStart?: number; + timeSpanDuration?: number; + skipFrames?: number; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += emitStatusHelper(); + + script += 'var rqItem = app.project.renderQueue.items.add(comp);\n'; + + // Render settings template must be applied before output module settings, + // because applying a render template can reset the output modules. + if (params.renderSettingsTemplate) { + script += 'rqItem.applyTemplate("' + escapeString(params.renderSettingsTemplate) + '");\n'; + } + if (params.outputModuleTemplate) { + script += 'rqItem.outputModule(1).applyTemplate("' + escapeString(params.outputModuleTemplate) + '");\n'; + } + if (params.outputPath) { + script += 'rqItem.outputModule(1).file = new File("' + escapeString(params.outputPath) + '");\n'; + } + if (params.timeSpanStart !== undefined) { + script += 'rqItem.timeSpanStart = ' + params.timeSpanStart + ';\n'; + } + if (params.timeSpanDuration !== undefined) { + script += 'rqItem.timeSpanDuration = ' + params.timeSpanDuration + ';\n'; + } + if (params.skipFrames !== undefined) { + script += 'rqItem.skipFrames = ' + params.skipFrames + ';\n'; + } + + script += generateResultObject({ + queueIndex: 'app.project.renderQueue.numItems', + compName: 'comp.name', + status: 'statusName(rqItem.status)', + outputPath: 'rqItem.outputModule(1).file ? rqItem.outputModule(1).file.fsName : null', + timeSpanStart: 'rqItem.timeSpanStart', + timeSpanDuration: 'rqItem.timeSpanDuration' + }); + + return wrapInUndoGroup(script, 'Add to Render Queue'); +} + +/** + * List the render queue with per-item status and output paths. + */ +export function generateListRenderQueue(_params: Record = {} as Record): string { + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + + script += 'var rq = app.project.renderQueue;\n'; + script += 'var items = [];\n'; + script += 'for (var i = 1; i <= rq.numItems; i++) {\n'; + script += ' var it = rq.item(i);\n'; + script += ' var mods = [];\n'; + script += ' for (var m = 1; m <= it.numOutputModules; m++) {\n'; + script += ' var om = it.outputModule(m);\n'; + script += ' mods.push({\n'; + script += ' index: m,\n'; + script += ' name: om.name,\n'; + script += ' file: om.file ? om.file.fsName : null\n'; + script += ' });\n'; + script += ' }\n'; + script += ' items.push({\n'; + script += ' index: i,\n'; + script += ' compName: it.comp.name,\n'; + script += ' status: statusName(it.status),\n'; + script += ' timeSpanStart: it.timeSpanStart,\n'; + script += ' timeSpanDuration: it.timeSpanDuration,\n'; + script += ' skipFrames: it.skipFrames,\n'; + script += ' elapsedSeconds: it.elapsedSeconds,\n'; + script += ' outputModules: mods\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + numItems: 'rq.numItems', + rendering: 'rq.rendering', + canQueueInAME: 'rq.canQueueInAME', + items: 'items' + }); + + return script; +} + +/** + * List the available render settings and output module templates, so callers + * can use the presets already configured in After Effects by name rather than + * trying to reconstruct settings. + */ +export function generateListRenderTemplates(_params: Record = {} as Record): string { + let script = ''; + script += generateProjectCheck(); + + // Templates are read off a queue item, so use a temporary one when the queue + // is empty, then remove it again. + script += 'var rq = app.project.renderQueue;\n'; + script += 'var tempItem = null;\n'; + script += 'var probe = null;\n'; + script += 'if (rq.numItems > 0) {\n'; + script += ' probe = rq.item(1);\n'; + script += '} else {\n'; + script += ' var tempComp = app.project.items.addComp("__rq_template_probe__", 16, 16, 1, 1, 24);\n'; + script += ' tempItem = rq.items.add(tempComp);\n'; + script += ' probe = tempItem;\n'; + script += '}\n'; + script += 'var renderTemplates = probe.templates;\n'; + script += 'var omTemplates = probe.outputModule(1).templates;\n'; + script += 'if (tempItem !== null) {\n'; + script += ' var tc = tempItem.comp;\n'; + script += ' tempItem.remove();\n'; + script += ' tc.remove();\n'; + script += '}\n'; + + script += generateResultObject({ + renderSettingsTemplates: 'renderTemplates', + outputModuleTemplates: 'omTemplates' + }); + + return script; +} + +/** + * Change settings on an existing render queue item. + */ +export function generateSetRenderQueueItem(params: { + itemIndex: number; + outputPath?: string; + renderSettingsTemplate?: string; + outputModuleTemplate?: string; + outputModuleIndex?: number; + timeSpanStart?: number; + timeSpanDuration?: number; + skipFrames?: number; + render?: boolean; +}): string { + if (typeof params.itemIndex !== 'number') { + throw new Error('itemIndex is required (1-based render queue index)'); + } + + const omIndex = params.outputModuleIndex || 1; + + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + script += emitRQItemAccess(params.itemIndex); + + if (params.renderSettingsTemplate) { + script += 'rqItem.applyTemplate("' + escapeString(params.renderSettingsTemplate) + '");\n'; + } + if (params.outputModuleTemplate) { + script += 'rqItem.outputModule(' + omIndex + ').applyTemplate("' + escapeString(params.outputModuleTemplate) + '");\n'; + } + if (params.outputPath) { + script += 'rqItem.outputModule(' + omIndex + ').file = new File("' + escapeString(params.outputPath) + '");\n'; + } + if (params.timeSpanStart !== undefined) { + script += 'rqItem.timeSpanStart = ' + params.timeSpanStart + ';\n'; + } + if (params.timeSpanDuration !== undefined) { + script += 'rqItem.timeSpanDuration = ' + params.timeSpanDuration + ';\n'; + } + if (params.skipFrames !== undefined) { + script += 'rqItem.skipFrames = ' + params.skipFrames + ';\n'; + } + if (params.render !== undefined) { + // Controls whether this item is checked/queued for the next render pass. + script += 'rqItem.render = ' + (params.render ? 'true' : 'false') + ';\n'; + } + + script += generateResultObject({ + index: String(params.itemIndex), + compName: 'rqItem.comp.name', + status: 'statusName(rqItem.status)', + outputPath: 'rqItem.outputModule(' + omIndex + ').file ? rqItem.outputModule(' + omIndex + ').file.fsName : null' + }); + + return wrapInUndoGroup(script, 'Set Render Queue Item'); +} + +/** + * Remove an item from the render queue, or clear the whole queue. + */ +export function generateRemoveFromRenderQueue(params: { + itemIndex?: number; + all?: boolean; +}): string { + let script = ''; + script += generateProjectCheck(); + script += 'var rq = app.project.renderQueue;\n'; + + if (params.all) { + script += 'var removed = rq.numItems;\n'; + script += 'for (var i = rq.numItems; i >= 1; i--) {\n'; + script += ' rq.item(i).remove();\n'; + script += '}\n'; + } else { + if (typeof params.itemIndex !== 'number') { + throw new Error('Provide itemIndex, or all: true to clear the queue'); + } + script += emitRQItemAccess(params.itemIndex); + script += 'rqItem.remove();\n'; + script += 'var removed = 1;\n'; + } + + script += generateResultObject({ + removed: 'removed', + remaining: 'rq.numItems' + }); + + return wrapInUndoGroup(script, 'Remove from Render Queue'); +} + +/** + * Start, stop or pause rendering. + * + * NOTE ON 'start': RenderQueue.render() is SYNCHRONOUS. After Effects blocks + * until the whole queue finishes, so the bridge will not return a response + * until then and will usually hit its command timeout on any real render. Use + * queue_in_ame for long jobs, or start the render and poll list_render_queue. + */ +export function generateControlRender(params: { + action: 'start' | 'stop' | 'pause' | 'resume' | 'showWindow'; +}): string { + const action = params.action; + const valid = ['start', 'stop', 'pause', 'resume', 'showWindow']; + if (valid.indexOf(action) === -1) { + throw new Error('action must be one of: ' + valid.join(', ')); + } + + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + script += 'var rq = app.project.renderQueue;\n'; + + if (action === 'start') { + script += 'if (rq.numItems === 0) { throw new Error("Render queue is empty"); }\n'; + script += 'rq.render();\n'; + } else if (action === 'stop') { + script += 'rq.stopRendering();\n'; + } else if (action === 'pause') { + script += 'rq.pauseRendering(true);\n'; + } else if (action === 'resume') { + script += 'rq.pauseRendering(false);\n'; + } else { + script += 'rq.showWindow(true);\n'; + } + + script += generateResultObject({ + action: '"' + action + '"', + rendering: 'rq.rendering', + numItems: 'rq.numItems' + }); + + return script; +} + +/** + * Hand the render queue to Adobe Media Encoder. + * + * Unlike render(), this returns immediately - AME does the work in its own + * process - which makes it the better choice for anything long. + */ +export function generateQueueInAME(params: { renderImmediately?: boolean }): string { + const immediate = params.renderImmediately === true; + + let script = ''; + script += generateProjectCheck(); + script += 'var rq = app.project.renderQueue;\n'; + script += 'if (!rq.canQueueInAME) {\n'; + script += ' throw new Error("Cannot queue in AME. Check that Adobe Media Encoder is installed and that the queue has at least one item needing output.");\n'; + script += '}\n'; + script += 'rq.queueInAME(' + (immediate ? 'true' : 'false') + ');\n'; + + script += generateResultObject({ + queuedInAME: 'true', + startedProcessing: immediate ? 'true' : 'false', + numItems: 'rq.numItems' + }); + + return script; +} diff --git a/src/ae-integration/scriptGenerator.ts b/src/ae-integration/scriptGenerator.ts index b121987..cfce1d8 100644 --- a/src/ae-integration/scriptGenerator.ts +++ b/src/ae-integration/scriptGenerator.ts @@ -130,6 +130,17 @@ export { SHAPE_MATCH_NAMES } from './generators/shapeGenerators.js'; +// Render queue generators +export { + generateAddToRenderQueue, + generateListRenderQueue, + generateListRenderTemplates, + generateSetRenderQueueItem, + generateRemoveFromRenderQueue, + generateControlRender, + generateQueueInAME +} from './generators/renderQueueGenerators.js'; + // Helpers (for direct use if needed) export { escapeString, diff --git a/src/stdio-server.ts b/src/stdio-server.ts index 737dc8d..caa0c6e 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -436,6 +436,91 @@ const TOOLS = [ }, generator: generators.generateAddShapeOperator }, + { + name: 'add_to_render_queue', + description: 'Add a composition to the render queue, optionally applying render settings and output module templates and an output path', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + outputPath: { type: 'string', description: 'Absolute output file path. NOTE: the output module template decides the actual container/extension - passing a .mov path while the module is set to H.264 produces a .mp4. Check the returned outputPath.' }, + renderSettingsTemplate: { type: 'string', description: 'Name of an existing render settings template (see list_render_templates)' }, + outputModuleTemplate: { type: 'string', description: 'Name of an existing output module template (see list_render_templates)' }, + timeSpanStart: { type: 'number', description: 'Render start time in seconds' }, + timeSpanDuration: { type: 'number', description: 'Render duration in seconds' }, + skipFrames: { type: 'number', description: 'Frames to skip between rendered frames (0 renders every frame)' } + } + }, + generator: generators.generateAddToRenderQueue + }, + { + name: 'list_render_queue', + description: 'List every render queue item with its status, time span and output modules. Use this to poll progress while a render runs.', + inputSchema: { type: 'object', properties: {} }, + generator: generators.generateListRenderQueue + }, + { + name: 'list_render_templates', + description: 'List the render settings and output module templates configured in this After Effects install, so renders can reuse existing presets by name', + inputSchema: { type: 'object', properties: {} }, + generator: generators.generateListRenderTemplates + }, + { + name: 'set_render_queue_item', + description: 'Change an existing render queue item - output path, templates, time span, or whether it is queued to render', + inputSchema: { + type: 'object', + properties: { + itemIndex: { type: 'number', description: '1-based render queue index' }, + outputPath: { type: 'string' }, + renderSettingsTemplate: { type: 'string' }, + outputModuleTemplate: { type: 'string' }, + outputModuleIndex: { type: 'number', description: 'Which output module to change (default 1)' }, + timeSpanStart: { type: 'number' }, + timeSpanDuration: { type: 'number' }, + skipFrames: { type: 'number' }, + render: { type: 'boolean', description: 'Whether this item is checked for the next render pass' } + }, + required: ['itemIndex'] + }, + generator: generators.generateSetRenderQueueItem + }, + { + name: 'remove_from_render_queue', + description: 'Remove one render queue item, or clear the entire queue with all: true', + inputSchema: { + type: 'object', + properties: { + itemIndex: { type: 'number', description: '1-based render queue index' }, + all: { type: 'boolean', description: 'Clear the whole queue' } + } + }, + generator: generators.generateRemoveFromRenderQueue + }, + { + name: 'control_render', + description: 'Start, stop, pause or resume rendering, or show the Render Queue panel. WARNING: start is synchronous - After Effects blocks until the queue finishes, so this call will usually time out on any real render. Prefer queue_in_ame for long jobs, or start and then poll list_render_queue.', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['start', 'stop', 'pause', 'resume', 'showWindow'] } + }, + required: ['action'] + }, + generator: generators.generateControlRender + }, + { + name: 'queue_in_ame', + description: 'Send the render queue to Adobe Media Encoder. Returns immediately because AME renders in its own process, which makes it the better choice for long jobs. Set renderImmediately to start AME processing rather than only queueing.', + inputSchema: { + type: 'object', + properties: { + renderImmediately: { type: 'boolean', description: 'Also start AME processing its queue (default false)' } + } + }, + generator: generators.generateQueueInAME + }, { name: 'list_project_items', description: 'List every item in the project, optionally filtered by type', From 609858cd0f643e41205e2b80a1f8d5525b3bac81 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:53:11 -0400 Subject: [PATCH 05/25] feat(masks): add mask creation, animation and property tools Masks had no coverage at all. They reuse the Shape object that shape layer paths use, so path building, validation and tangent handling are shared with shapeGenerators rather than reimplemented - validatePathPoints, zeroTangents and emitShapeObject are now exported for that. add_mask add a mask, optionally with a bezier path and full settings in one call list_masks every mask with mode, feather, opacity, expansion and whether its path is animated get_mask_path read a path back, keyframes included set_mask_path replace a path set_mask_keyframes animate a path set_mask_properties change settings without touching the path delete_mask remove a mask Match names verified empirically against AE 26.5, since the scripting guide documents only ADBE Mask Parade for this group: ADBE Mask Shape path (Shape-valued) ADBE Mask Feather feather ([x, y] - a single number is applied to both axes for convenience) ADBE Mask Opacity opacity ADBE Mask Offset expansion - note the name does not match the UI set_mask_properties temporarily unlocks a locked mask, applies the changes, then restores the requested lock state. A locked mask silently rejects edits otherwise, which is an easy way to lose work without an error. Mask modes are validated in TypeScript against the MaskMode enum, so an invalid mode is rejected before any script reaches After Effects. Verified end to end against AE 26.5: create with a bezier path, list, read back, animate with 3 keys, edit through a lock, delete. Co-Authored-By: Claude Opus 5 --- .../generators/maskGenerators.ts | 457 ++++++++++++++++++ .../generators/shapeGenerators.ts | 6 +- src/ae-integration/scriptGenerator.ts | 12 + src/stdio-server.ts | 150 ++++++ 4 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 src/ae-integration/generators/maskGenerators.ts diff --git a/src/ae-integration/generators/maskGenerators.ts b/src/ae-integration/generators/maskGenerators.ts new file mode 100644 index 0000000..bb48fa1 --- /dev/null +++ b/src/ae-integration/generators/maskGenerators.ts @@ -0,0 +1,457 @@ +/** + * Mask Generators + * + * Masks and shape-layer paths both use the same Shape object, so the path + * building and validation is shared with shapeGenerators rather than + * reimplemented. + * + * Match names verified empirically against After Effects 26.5: + * ADBE Mask Parade the mask group on a layer + * ADBE Mask Atom one mask + * ADBE Mask Shape the path (Shape-valued) + * ADBE Mask Feather feather ([x, y], TwoD) + * ADBE Mask Opacity opacity (percent) + * ADBE Mask Offset expansion (pixels) - note the name mismatch + */ + +import { + escapeString, + colorToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +import { + validatePathPoints, + zeroTangents, + emitShapeObject +} from './shapeGenerators.js'; + +export const MASK_MATCH_NAMES = { + parade: 'ADBE Mask Parade', + atom: 'ADBE Mask Atom', + path: 'ADBE Mask Shape', + feather: 'ADBE Mask Feather', + opacity: 'ADBE Mask Opacity', + expansion: 'ADBE Mask Offset' +}; + +const MASK_MODES = ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference']; + +/** Map a friendly mode string to its MaskMode constant. */ +function maskModeES3(mode: string): string { + const m = mode.toLowerCase(); + if (MASK_MODES.indexOf(m) === -1) { + throw new Error('mode must be one of: ' + MASK_MODES.join(', ')); + } + return 'MaskMode.' + m.toUpperCase(); +} + +/** Emit ES3 resolving `maskGroup`, and `mask` when a mask is identified. */ +function emitMaskAccess(maskIndex?: number, maskName?: string): string { + let s = ''; + s += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + s += 'if (!maskGroup) { throw new Error("Layer does not support masks: " + layer.name); }\n'; + + if (maskIndex !== undefined) { + s += 'if (' + maskIndex + ' < 1 || ' + maskIndex + ' > maskGroup.numProperties) {\n'; + s += ' throw new Error("Mask index ' + maskIndex + ' out of range; layer has " + maskGroup.numProperties + " mask(s)");\n'; + s += '}\n'; + s += 'var mask = maskGroup.property(' + maskIndex + ');\n'; + } else if (maskName !== undefined) { + s += 'var mask = null;\n'; + s += 'for (var mi = 1; mi <= maskGroup.numProperties; mi++) {\n'; + s += ' if (maskGroup.property(mi).name === "' + escapeString(maskName) + '") {\n'; + s += ' mask = maskGroup.property(mi);\n'; + s += ' break;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (mask === null) { throw new Error("Mask not found: ' + escapeString(maskName) + '"); }\n'; + } else { + s += 'if (maskGroup.numProperties === 0) { throw new Error("Layer has no masks: " + layer.name); }\n'; + s += 'var mask = maskGroup.property(1);\n'; + } + return s; +} + +/** + * Emit the shared mask-settings assignments used by add_mask and + * set_mask_properties. + */ +function emitMaskSettings(params: { + mode?: string; + inverted?: boolean; + locked?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; + name?: string; +}): string { + let s = ''; + if (params.name !== undefined) { + s += 'mask.name = "' + escapeString(params.name) + '";\n'; + } + if (params.mode !== undefined) { + s += 'mask.maskMode = ' + maskModeES3(params.mode) + ';\n'; + } + if (params.inverted !== undefined) { + s += 'mask.inverted = ' + (params.inverted ? 'true' : 'false') + ';\n'; + } + if (params.locked !== undefined) { + // Set locked last where possible - a locked mask rejects further edits. + s += 'mask.locked = ' + (params.locked ? 'true' : 'false') + ';\n'; + } + if (params.rotoBezier !== undefined) { + s += 'mask.rotoBezier = ' + (params.rotoBezier ? 'true' : 'false') + ';\n'; + } + if (params.color) { + s += 'mask.color = ' + colorToES3(params.color) + ';\n'; + } + if (params.feather !== undefined) { + // Feather is a TwoD property - accept a single number for both axes. + const f = Array.isArray(params.feather) + ? '[' + params.feather[0] + ', ' + params.feather[1] + ']' + : '[' + params.feather + ', ' + params.feather + ']'; + s += 'mask.property("' + MASK_MATCH_NAMES.feather + '").setValue(' + f + ');\n'; + } + if (params.opacity !== undefined) { + s += 'mask.property("' + MASK_MATCH_NAMES.opacity + '").setValue(' + params.opacity + ');\n'; + } + if (params.expansion !== undefined) { + s += 'mask.property("' + MASK_MATCH_NAMES.expansion + '").setValue(' + params.expansion + ');\n'; + } + return s; +} + +/** + * Add a mask to a layer, with an optional path. + */ +export function generateAddMask(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + name?: string; + vertices?: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + mode?: string; + inverted?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; +}): string { + if (params.vertices) { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + + script += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + script += 'if (!maskGroup) { throw new Error("Layer does not support masks: " + layer.name); }\n'; + script += 'var mask = maskGroup.addProperty("' + MASK_MATCH_NAMES.atom + '");\n'; + + if (params.vertices) { + const inT = params.inTangents || zeroTangents(params.vertices.length); + const outT = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + script += emitShapeObject('maskShape', params.vertices, inT, outT, closed); + script += 'mask.property("' + MASK_MATCH_NAMES.path + '").setValue(maskShape);\n'; + } + + script += emitMaskSettings(params); + + // Read values out before anything else can invalidate the reference. + script += 'var createdMaskName = mask.name;\n'; + script += 'var createdMaskIndex = maskGroup.numProperties;\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'createdMaskName', + maskIndex: 'createdMaskIndex', + vertexCount: params.vertices ? String(params.vertices.length) : '0' + }); + + return wrapInUndoGroup(script, 'Add Mask'); +} + +/** + * List every mask on a layer with its settings. + */ +export function generateListMasks(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + + script += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + script += 'var masks = [];\n'; + script += 'if (maskGroup) {\n'; + script += ' for (var i = 1; i <= maskGroup.numProperties; i++) {\n'; + script += ' var m = maskGroup.property(i);\n'; + script += ' var pathProp = m.property("' + MASK_MATCH_NAMES.path + '");\n'; + script += ' masks.push({\n'; + script += ' index: i,\n'; + script += ' name: m.name,\n'; + script += ' inverted: m.inverted,\n'; + script += ' locked: m.locked,\n'; + script += ' rotoBezier: m.rotoBezier,\n'; + script += ' numVertices: pathProp.value.vertices.length,\n'; + script += ' pathIsAnimated: pathProp.numKeys > 0,\n'; + script += ' numPathKeys: pathProp.numKeys,\n'; + script += ' feather: m.property("' + MASK_MATCH_NAMES.feather + '").value,\n'; + script += ' opacity: m.property("' + MASK_MATCH_NAMES.opacity + '").value,\n'; + script += ' expansion: m.property("' + MASK_MATCH_NAMES.expansion + '").value\n'; + script += ' });\n'; + script += ' }\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + numMasks: 'masks.length', + masks: 'masks' + }); + + return script; +} + +/** + * Read a mask path back, including keyframes when animated. + */ +export function generateGetMaskPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var pathProp = mask.property("' + MASK_MATCH_NAMES.path + '");\n'; + script += 'var current = pathProp.value;\n'; + script += 'var keys = [];\n'; + script += 'for (var k = 1; k <= pathProp.numKeys; k++) {\n'; + script += ' var kv = pathProp.keyValue(k);\n'; + script += ' keys.push({\n'; + script += ' time: pathProp.keyTime(k),\n'; + script += ' vertices: kv.vertices,\n'; + script += ' inTangents: kv.inTangents,\n'; + script += ' outTangents: kv.outTangents,\n'; + script += ' closed: kv.closed\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + vertices: 'current.vertices', + inTangents: 'current.inTangents', + outTangents: 'current.outTangents', + closed: 'current.closed', + isAnimated: 'pathProp.numKeys > 0', + numKeys: 'pathProp.numKeys', + keyframes: 'keys' + }); + + return script; +} + +/** + * Replace a mask's path. + */ +export function generateSetMaskPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; +}): string { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + + const inT = params.inTangents || zeroTangents(params.vertices.length); + const outT = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += emitShapeObject('maskShape', params.vertices, inT, outT, closed); + script += 'mask.property("' + MASK_MATCH_NAMES.path + '").setValue(maskShape);\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + vertexCount: String(params.vertices.length) + }); + + return wrapInUndoGroup(script, 'Set Mask Path'); +} + +/** + * Animate a mask path. + * + * As with shape paths, Shape-valued properties must be keyed one at a time - + * setValuesAtTimes() does not work on them. + */ +export function generateSetMaskKeyframes(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + keyframes: Array<{ + time: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + }>; +}): string { + if (!params.keyframes || !Array.isArray(params.keyframes) || params.keyframes.length === 0) { + throw new Error('keyframes must be a non-empty array'); + } + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + if (typeof kf.time !== 'number') { + throw new Error('keyframes[' + i + '].time must be a number'); + } + validatePathPoints(kf.vertices, kf.inTangents, kf.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var pathProp = mask.property("' + MASK_MATCH_NAMES.path + '");\n'; + + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + const inT = kf.inTangents || zeroTangents(kf.vertices.length); + const outT = kf.outTangents || zeroTangents(kf.vertices.length); + const cl = kf.closed !== undefined ? kf.closed : true; + script += emitShapeObject('mkShape' + i, kf.vertices, inT, outT, cl); + script += 'pathProp.setValueAtTime(' + kf.time + ', mkShape' + i + ');\n'; + } + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + keysWritten: String(params.keyframes.length), + numKeys: 'pathProp.numKeys' + }); + + return wrapInUndoGroup(script, 'Set Mask Keyframes'); +} + +/** + * Change a mask's settings without touching its path. + */ +export function generateSetMaskProperties(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + name?: string; + mode?: string; + inverted?: boolean; + locked?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + // Unlock first so the other assignments are not rejected, then re-apply the + // requested lock state at the end. + script += 'var wasLocked = mask.locked;\n'; + script += 'if (wasLocked) { mask.locked = false; }\n'; + + const settings = { ...params }; + delete settings.locked; + script += emitMaskSettings(settings); + + if (params.locked !== undefined) { + script += 'mask.locked = ' + (params.locked ? 'true' : 'false') + ';\n'; + } else { + script += 'mask.locked = wasLocked;\n'; + } + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + locked: 'mask.locked', + inverted: 'mask.inverted' + }); + + return wrapInUndoGroup(script, 'Set Mask Properties'); +} + +/** + * Delete a mask. + */ +export function generateDeleteMask(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var removedName = mask.name;\n'; + script += 'if (mask.locked) { mask.locked = false; }\n'; + script += 'mask.remove();\n'; + + script += generateResultObject({ + layerName: 'layer.name', + removed: 'removedName', + remainingMasks: 'maskGroup.numProperties' + }); + + return wrapInUndoGroup(script, 'Delete Mask'); +} diff --git a/src/ae-integration/generators/shapeGenerators.ts b/src/ae-integration/generators/shapeGenerators.ts index ea7f238..0f6388e 100644 --- a/src/ae-integration/generators/shapeGenerators.ts +++ b/src/ae-integration/generators/shapeGenerators.ts @@ -132,7 +132,7 @@ export const SHAPE_OPERATOR_PROPERTIES: Record> = * Validate a vertex list and its tangents at generate time, so bad input fails * here with a clear message instead of somewhere inside After Effects. */ -function validatePathPoints( +export function validatePathPoints( vertices: number[][], inTangents?: number[][], outTangents?: number[][] @@ -163,7 +163,7 @@ function validatePathPoints( } /** Zero tangents (corner points) for a vertex list. */ -function zeroTangents(count: number): number[][] { +export function zeroTangents(count: number): number[][] { const out: number[][] = []; for (let i = 0; i < count; i++) { out.push([0, 0]); @@ -177,7 +177,7 @@ function zeroTangents(count: number): number[][] { * Tangents are RELATIVE to their own vertex, not absolute comp coordinates. * This is the single most common source of mangled curves. */ -function emitShapeObject( +export function emitShapeObject( varName: string, vertices: number[][], inTangents: number[][], diff --git a/src/ae-integration/scriptGenerator.ts b/src/ae-integration/scriptGenerator.ts index cfce1d8..b148938 100644 --- a/src/ae-integration/scriptGenerator.ts +++ b/src/ae-integration/scriptGenerator.ts @@ -130,6 +130,18 @@ export { SHAPE_MATCH_NAMES } from './generators/shapeGenerators.js'; +// Mask generators +export { + generateAddMask, + generateListMasks, + generateGetMaskPath, + generateSetMaskPath, + generateSetMaskKeyframes, + generateSetMaskProperties, + generateDeleteMask, + MASK_MATCH_NAMES +} from './generators/maskGenerators.js'; + // Render queue generators export { generateAddToRenderQueue, diff --git a/src/stdio-server.ts b/src/stdio-server.ts index caa0c6e..2f0af46 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -436,6 +436,156 @@ const TOOLS = [ }, generator: generators.generateAddShapeOperator }, + { + name: 'add_mask', + description: 'Add a mask to a layer, optionally with a bezier path. Tangents are RELATIVE to their own vertex.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + name: { type: 'string', description: 'Mask name' }, + vertices: { type: 'array', description: 'Array of [x, y] points in layer space', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', description: 'Optional [x, y] per vertex, relative to that vertex', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', description: 'Optional [x, y] per vertex, relative to that vertex', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean', description: 'Close the path (default true)' }, + mode: { type: 'string', enum: ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference'] }, + inverted: { type: 'boolean' }, + rotoBezier: { type: 'boolean' }, + feather: { type: 'number', description: 'Feather in pixels, applied to both axes' }, + opacity: { type: 'number', description: 'Mask opacity percent' }, + expansion: { type: 'number', description: 'Mask expansion in pixels' }, + color: { type: 'object', description: 'Mask outline color (0-1 range)' } + } + }, + generator: generators.generateAddMask + }, + { + name: 'list_masks', + description: 'List every mask on a layer with its mode, feather, opacity, expansion and whether its path is animated', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' } + } + }, + generator: generators.generateListMasks + }, + { + name: 'get_mask_path', + description: 'Read a mask path back, including every keyframe when the path is animated', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number', description: '1-based mask index' }, + maskName: { type: 'string' } + } + }, + generator: generators.generateGetMaskPath + }, + { + name: 'set_mask_path', + description: 'Replace a mask path with new vertices and tangents', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['vertices'] + }, + generator: generators.generateSetMaskPath + }, + { + name: 'set_mask_keyframes', + description: 'Animate a mask path. Each keyframe carries its own full vertex list.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + keyframes: { + type: 'array', + description: 'Array of { time, vertices, inTangents?, outTangents?, closed? }', + items: { + type: 'object', + properties: { + time: { type: 'number', description: 'Time in seconds' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['time', 'vertices'] + } + } + }, + required: ['keyframes'] + }, + generator: generators.generateSetMaskKeyframes + }, + { + name: 'set_mask_properties', + description: 'Change a mask\'s mode, inversion, lock, feather, opacity, expansion or color without touching its path. A locked mask is temporarily unlocked so the changes apply.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + name: { type: 'string', description: 'Rename the mask' }, + mode: { type: 'string', enum: ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference'] }, + inverted: { type: 'boolean' }, + locked: { type: 'boolean' }, + rotoBezier: { type: 'boolean' }, + feather: { type: 'number' }, + opacity: { type: 'number' }, + expansion: { type: 'number' }, + color: { type: 'object', description: 'Mask outline color (0-1 range)' } + } + }, + generator: generators.generateSetMaskProperties + }, + { + name: 'delete_mask', + description: 'Delete a mask from a layer', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' } + } + }, + generator: generators.generateDeleteMask + }, { name: 'add_to_render_queue', description: 'Add a composition to the render queue, optionally applying render settings and output module templates and an output path', From fce46785304e0837e2bebe4886640ed434c9d0f1 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:45:50 -0400 Subject: [PATCH 06/25] feat(3d): add renderer, transform, material, geometry, camera and light tools 3D coverage was two shallow tools - add_camera_layer took type and zoom, add_light_layer took type, colour and intensity. Live After Effects exposes 15 camera and 14 light properties. set_comp_renderer switch the comp's 3D renderer get_3d_info renderer, available renderers, and a layer's current Material/Camera/Light/Geometry values set_3d_layer 3D switch plus orientation and rotation X/Y/Z set_material_options all 17 material properties set_geometry_options extrusion/bevel and plane curvature/segments set_camera_options all 15 camera properties set_light_options all 14 light properties The hard part is that layer.property(matchName) returns a live object even when the property cannot be set - setValue then throws "the property or a parent property is hidden" - and .active, .elided and .enabled all report identical values in the working and failing cases. There is no flag to test first. So setters apply properties individually and report applied[] and failed[] rather than aborting on the first refusal, and each hidden failure is annotated with availableUnderRenderers naming the renderers that actually expose it. Extrusion additionally fails fast with an actionable message, since it is the common case. Renderers are NOT a hierarchy - each exposes a different subset, measured on AE 26.5: Blur Level, Iris, Highlights classic only Depth of Field, Focus, Aperture classic + advanced Focus Area Width, Shadow Colour advanced only Appears in Reflections cinema4d only A first pass told callers to switch to "advanced" while they were already on it. Naming the specific renderer per property replaced that. Note that "ADBE Advanced 3d" is the CLASSIC renderer despite its name, and is the default for new comps. Two more name traps encoded: cone feather is "ADBE Light Cone Feather 2", and highlight saturation is "ADBE Iris Hightlight Saturation" - Adobe's own typo, which must be spelled wrong to work. Verified end to end against AE 26.5. Co-Authored-By: Claude Opus 5 --- .../generators/threeDGenerators.ts | 638 ++++++++++++++++++ src/ae-integration/scriptGenerator.ts | 19 + src/stdio-server.ts | 163 +++++ 3 files changed, 820 insertions(+) create mode 100644 src/ae-integration/generators/threeDGenerators.ts diff --git a/src/ae-integration/generators/threeDGenerators.ts b/src/ae-integration/generators/threeDGenerators.ts new file mode 100644 index 0000000..6e09153 --- /dev/null +++ b/src/ae-integration/generators/threeDGenerators.ts @@ -0,0 +1,638 @@ +/** + * 3D Generators + * + * Camera, light, material, geometry and 3D transform control. + * + * THE CENTRAL PROBLEM this module works around: on a 3D layer, + * layer.property(matchName) returns a live object even when the property + * cannot actually be set. Setting it then throws + * + * "Can not 'set value' with this property, because the property or a + * parent property is hidden." + * + * and .active / .elided / .enabled all report the SAME values in the working + * and failing cases, so there is no flag to test first. Extrusion on a Classic + * 3D comp is the common case. Every setter here therefore applies properties + * individually and reports which landed and which did not, with a renderer hint + * when the failures look renderer-related, rather than aborting on the first one + * or silently doing nothing. + * + * All match names verified empirically against After Effects 26.5. + */ + +import { + escapeString, + colorToES3, + arrayToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * comp.renderers values, with the capabilities each actually permits. + * + * NOTE: "ADBE Advanced 3d" is NOT the Advanced 3D renderer - it is the legacy + * Classic renderer and the default for new comps. Verified by behaviour: + * extrusion, environment layers, shadow colour and reflections are all refused + * under it. + */ +export const RENDERERS: Record = { + classic: 'ADBE Advanced 3d', + advanced: 'ADBE Calder', + cinema4d: 'ADBE Ernst' +}; + +/** Renderers under which extrusion and bevel can actually be set. */ +export const EXTRUSION_RENDERERS = ['ADBE Calder', 'ADBE Ernst']; + +export const TRANSFORM_3D_PROPERTIES: Record = { + anchorPoint: 'ADBE Anchor Point', + position: 'ADBE Position', + scale: 'ADBE Scale', + orientation: 'ADBE Orientation', + rotationX: 'ADBE Rotate X', + rotationY: 'ADBE Rotate Y', + rotationZ: 'ADBE Rotate Z', + opacity: 'ADBE Opacity' +}; + +export const MATERIAL_PROPERTIES: Record = { + castsShadows: 'ADBE Casts Shadows', + lightTransmission: 'ADBE Light Transmission', + acceptsShadows: 'ADBE Accepts Shadows', + acceptsLights: 'ADBE Accepts Lights', + shadowColor: 'ADBE Shadow Color', + appearsInReflections: 'ADBE Appears in Reflections', + ambient: 'ADBE Ambient Coefficient', + diffuse: 'ADBE Diffuse Coefficient', + specularIntensity: 'ADBE Specular Coefficient', + specularShininess: 'ADBE Shininess Coefficient', + metal: 'ADBE Metal Coefficient', + reflectionIntensity: 'ADBE Reflection Coefficient', + reflectionSharpness: 'ADBE Glossiness Coefficient', + reflectionRolloff: 'ADBE Fresnel Coefficient', + transparency: 'ADBE Transparency Coefficient', + transparencyRolloff: 'ADBE Transp Rolloff', + indexOfRefraction: 'ADBE Index of Refraction' +}; + +export const CAMERA_PROPERTIES: Record = { + zoom: 'ADBE Camera Zoom', + depthOfField: 'ADBE Camera Depth of Field', + focusDistance: 'ADBE Camera Focus Distance', + aperture: 'ADBE Camera Aperture', + blurLevel: 'ADBE Camera Blur Level', + focusAreaWidth: 'ADBE Camera Focus Area Width', + nearFarBlurLevel: 'ADBE Camera Split Blur Level', + irisShape: 'ADBE Iris Shape', + irisRotation: 'ADBE Iris Rotation', + irisRoundness: 'ADBE Iris Roundness', + irisAspectRatio: 'ADBE Iris Aspect Ratio', + irisDiffractionFringe: 'ADBE Iris Diffraction Fringe', + highlightGain: 'ADBE Iris Highlight Gain', + highlightThreshold: 'ADBE Iris Highlight Threshold', + // Adobe's own typo - "Hightlight". Must be spelled wrong to work. + highlightSaturation: 'ADBE Iris Hightlight Saturation' +}; + +export const LIGHT_PROPERTIES: Record = { + intensity: 'ADBE Light Intensity', + color: 'ADBE Light Color', + coneAngle: 'ADBE Light Cone Angle', + // The un-suffixed "ADBE Light Cone Feather" is the deprecated one. + coneFeather: 'ADBE Light Cone Feather 2', + falloff: 'ADBE Light Falloff Type', + radius: 'ADBE Light Falloff Start', + falloffDistance: 'ADBE Light Falloff Distance', + castsShadows: 'ADBE Casts Shadows', + shadowDarkness: 'ADBE Light Shadow Darkness', + shadowDiffusion: 'ADBE Light Shadow Diffusion', + backgroundVisible: 'ADBE Light Backgd Visible', + backgroundOpacity: 'ADBE Light Backgd Opacity', + backgroundBlur: 'ADBE Light Backgd Blur' +}; + +/** + * "Geometry Options" is TWO groups sharing one display name. + */ +export const EXTRUSION_PROPERTIES: Record = { + bevelStyle: 'ADBE Bevel Styles', + bevelDirection: 'ADBE Bevel Direction', + bevelDepth: 'ADBE Bevel Depth', + holeBevelDepth: 'ADBE Hole Bevel Depth', + extrusionDepth: 'ADBE Extrsn Depth' +}; + +export const PLANE_PROPERTIES: Record = { + curvature: 'ADBE Plane Curvature', + segments: 'ADBE Plane Subdivision' +}; + +const GROUP_MATCH_NAMES = { + transform: 'ADBE Transform Group', + material: 'ADBE Material Options Group', + camera: 'ADBE Camera Options Group', + light: 'ADBE Light Options Group', + extrusion: 'ADBE Extrsn Options Group', + plane: 'ADBE Plane Options Group' +}; + +/** + * Emit the shared ES3 helper that applies one property and records the outcome + * instead of throwing. Defines applied/failed arrays and trySet(). + */ +function emitTrySetHelper(): string { + let s = ''; + s += 'var applied = [];\n'; + s += 'var failed = [];\n'; + s += 'function trySet(group, mn, label, val) {\n'; + s += ' if (!group) {\n'; + s += ' failed.push({ property: label, matchName: mn, reason: "property group not present on this layer" });\n'; + s += ' return;\n'; + s += ' }\n'; + s += ' var p = group.property(mn);\n'; + s += ' if (!p) {\n'; + s += ' failed.push({ property: label, matchName: mn, reason: "property not present on this layer" });\n'; + s += ' return;\n'; + s += ' }\n'; + s += ' try {\n'; + s += ' p.setValue(val);\n'; + s += ' applied.push(label);\n'; + s += ' } catch (e) {\n'; + s += ' var msg = e.toString();\n'; + s += ' msg = msg.replace("Error: After Effects error: ", "");\n'; + s += ' failed.push({ property: label, matchName: mn, reason: msg });\n'; + s += ' }\n'; + s += '}\n'; + return s; +} + +/** + * Which renderers actually permit a given property to be set. + * + * Measured directly on AE 26.5 - this is NOT a hierarchy. Each renderer + * exposes a different subset: the classic camera model (blur level, iris, + * highlights) exists only under "classic", focus area width and shadow colour + * only under "advanced", and the reflection properties only under "cinema4d", + * which hides the camera options entirely. + * + * Properties absent from this table are not known to be renderer-gated and get + * the generic hint instead of a specific one. + */ +const PROPERTY_RENDERER_SUPPORT: Record = { + 'ADBE Camera Blur Level': ['classic'], + 'ADBE Iris Shape': ['classic'], + 'ADBE Iris Rotation': ['classic'], + 'ADBE Iris Roundness': ['classic'], + 'ADBE Iris Aspect Ratio': ['classic'], + 'ADBE Iris Diffraction Fringe': ['classic'], + 'ADBE Iris Highlight Gain': ['classic'], + 'ADBE Iris Highlight Threshold': ['classic'], + 'ADBE Iris Hightlight Saturation': ['classic'], + 'ADBE Camera Depth of Field': ['classic', 'advanced'], + 'ADBE Camera Focus Distance': ['classic', 'advanced'], + 'ADBE Camera Aperture': ['classic', 'advanced'], + 'ADBE Camera Focus Area Width': ['advanced'], + 'ADBE Shadow Color': ['advanced'], + 'ADBE Appears in Reflections': ['cinema4d'], + 'ADBE Reflection Coefficient': ['cinema4d'], + 'ADBE Extrsn Depth': ['advanced', 'cinema4d'], + 'ADBE Bevel Styles': ['advanced', 'cinema4d'], + 'ADBE Bevel Direction': ['advanced', 'cinema4d'], + 'ADBE Bevel Depth': ['advanced', 'cinema4d'], + 'ADBE Hole Bevel Depth': ['advanced', 'cinema4d'] +}; + +/** Emit the support table as an ES3 object literal. */ +function emitSupportTable(): string { + let s = 'var rendererSupport = {};\n'; + for (const mn in PROPERTY_RENDERER_SUPPORT) { + if (!Object.prototype.hasOwnProperty.call(PROPERTY_RENDERER_SUPPORT, mn)) continue; + const list = PROPERTY_RENDERER_SUPPORT[mn].map(r => '"' + r + '"').join(', '); + s += 'rendererSupport["' + mn + '"] = [' + list + '];\n'; + } + return s; +} + +/** + * Annotate each hidden failure with the renderers that DO expose it, and emit a + * short summary hint. + * + * The earlier version told callers to switch to "advanced" even when they were + * already on it, which was worse than saying nothing. + */ +function emitRendererHint(): string { + let s = ''; + s += emitSupportTable(); + s += 'var rendererKey = "unknown";\n'; + s += 'if (comp.renderer === "ADBE Advanced 3d") { rendererKey = "classic"; }\n'; + s += 'else if (comp.renderer === "ADBE Calder") { rendererKey = "advanced"; }\n'; + s += 'else if (comp.renderer === "ADBE Ernst") { rendererKey = "cinema4d"; }\n'; + s += 'var hint = null;\n'; + s += 'var hiddenCount = 0;\n'; + s += 'for (var fi = 0; fi < failed.length; fi++) {\n'; + s += ' if (failed[fi].reason.indexOf("hidden") === -1) { continue; }\n'; + s += ' hiddenCount++;\n'; + s += ' var support = rendererSupport[failed[fi].matchName];\n'; + s += ' if (support) {\n'; + s += ' failed[fi].availableUnderRenderers = support;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (hiddenCount > 0) {\n'; + s += ' hint = hiddenCount + " property(s) refused as hidden. This comp uses the \\"" + rendererKey + "\\" renderer (" + comp.renderer + "). '; + s += 'Each renderer exposes a different subset, so check availableUnderRenderers on each failure and switch with set_comp_renderer if needed. '; + s += 'A property with no availableUnderRenderers is not renderer-gated - it likely needs a prerequisite, such as Depth of Field being enabled, or a layer type that supports it (extrusion is text and shape layers only).";\n'; + s += '}\n'; + return s; +} + +/** Convert a JS value into an ES3 literal suitable for setValue. */ +function valueToES3(value: unknown): string { + if (typeof value === 'boolean') { + // AE checkbox properties take 1/0. + return value ? '1' : '0'; + } + if (typeof value === 'number') { + return String(value); + } + if (Array.isArray(value)) { + return arrayToES3(value as unknown[]); + } + if (value && typeof value === 'object') { + const c = value as { r?: number; g?: number; b?: number }; + if (typeof c.r === 'number' && typeof c.g === 'number' && typeof c.b === 'number') { + return colorToES3(c as { r: number; g: number; b: number }); + } + } + throw new Error('Unsupported value type: ' + JSON.stringify(value)); +} + +/** + * Emit trySet() calls for every provided key in a property map. + * Returns the emitted script plus how many properties were requested. + */ +function emitPropertyApplications( + groupVar: string, + map: Record, + params: Record, + label: string +): { script: string; count: number } { + let s = ''; + let count = 0; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + if (!Object.prototype.hasOwnProperty.call(map, key)) continue; + const value = params[key]; + if (value === undefined) continue; + s += 'trySet(' + groupVar + ', "' + map[key] + '", "' + escapeString(key) + '", ' + valueToES3(value) + ');\n'; + count++; + } + if (count === 0) { + throw new Error( + 'No ' + label + ' properties supplied. Valid keys: ' + Object.keys(map).join(', ') + ); + } + return { script: s, count }; +} + +/** Reject unknown keys early with a helpful list. */ +function rejectUnknownKeys( + params: Record, + map: Record, + reserved: string[], + label: string +): void { + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + if (params[key] === undefined) continue; + if (reserved.indexOf(key) !== -1) continue; + if (!Object.prototype.hasOwnProperty.call(map, key)) { + throw new Error( + 'Unknown ' + label + ' property "' + key + '". Valid: ' + Object.keys(map).join(', ') + ); + } + } +} + +const LAYER_REF_KEYS = ['compId', 'compName', 'layerIndex', 'layerName']; + +/** + * Set the composition's 3D renderer. + * + * This is the unlock for most of the rest of this module - extrusion, bevel, + * environment layers and reflections are all inert under the Classic renderer. + */ +export function generateSetCompRenderer(params: { + compId?: number; + compName?: string; + renderer: string; +}): string { + let target: string; + if (Object.prototype.hasOwnProperty.call(RENDERERS, params.renderer)) { + target = RENDERERS[params.renderer]; + } else if (params.renderer && params.renderer.indexOf('ADBE ') === 0) { + target = params.renderer; + } else { + throw new Error( + 'renderer must be one of: ' + Object.keys(RENDERERS).join(', ') + + ' (or a raw "ADBE ..." string)' + ); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + script += 'var available = comp.renderers;\n'; + script += 'var found = false;\n'; + script += 'for (var i = 0; i < available.length; i++) {\n'; + script += ' if (available[i] === "' + escapeString(target) + '") { found = true; break; }\n'; + script += '}\n'; + script += 'if (!found) {\n'; + script += ' throw new Error("Renderer \\"' + escapeString(target) + '\\" is not available in this After Effects install. Available: " + available.join(", "));\n'; + script += '}\n'; + script += 'var previous = comp.renderer;\n'; + script += 'comp.renderer = "' + escapeString(target) + '";\n'; + + script += generateResultObject({ + compName: 'comp.name', + previousRenderer: 'previous', + renderer: 'comp.renderer', + availableRenderers: 'available', + supportsExtrusion: 'comp.renderer !== "ADBE Advanced 3d"' + }); + + return wrapInUndoGroup(script, 'Set Comp Renderer'); +} + +/** + * Enable 3D on a layer and set its 3D transform properties. + * + * Note: the separated X/Y/Z Position properties (ADBE Position_0/1/2) are + * read-only unless Separate Dimensions is enabled, so position is set through + * the combined ADBE Position property. + */ +export function generateSet3DLayer(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + enable3D?: boolean; + anchorPoint?: number[]; + position?: number[]; + scale?: number[]; + orientation?: number[]; + rotationX?: number; + rotationY?: number; + rotationZ?: number; + opacity?: number; +}): string { + rejectUnknownKeys( + params as Record, + TRANSFORM_3D_PROPERTIES, + LAYER_REF_KEYS.concat(['enable3D']), + '3D transform' + ); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitTrySetHelper(); + + if (params.enable3D !== undefined) { + script += 'if (typeof layer.threeDLayer === "undefined") {\n'; + script += ' throw new Error("Layer does not support 3D: " + layer.name);\n'; + script += '}\n'; + script += 'layer.threeDLayer = ' + (params.enable3D ? 'true' : 'false') + ';\n'; + } + + script += 'var xform = layer.property("' + GROUP_MATCH_NAMES.transform + '");\n'; + + // Only emit property applications when at least one was supplied; toggling + // 3D alone is a valid call. + const transformKeys = Object.keys(TRANSFORM_3D_PROPERTIES).filter( + k => (params as Record)[k] !== undefined + ); + if (transformKeys.length > 0) { + script += emitPropertyApplications( + 'xform', + TRANSFORM_3D_PROPERTIES, + params as Record, + '3D transform' + ).script; + } + + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + is3D: 'layer.threeDLayer', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set 3D Layer'); +} + +/** + * Set Material Options on a 3D layer. + */ +export function generateSetMaterialOptions(params: Record): string { + rejectUnknownKeys(params, MATERIAL_PROPERTIES, LAYER_REF_KEYS, 'material'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var matGroup = layer.property("' + GROUP_MATCH_NAMES.material + '");\n'; + script += 'if (!matGroup) {\n'; + script += ' throw new Error("Layer has no Material Options. Enable 3D on it first (set_3d_layer with enable3D: true).");\n'; + script += '}\n'; + script += emitPropertyApplications('matGroup', MATERIAL_PROPERTIES, params, 'material').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Material Options'); +} + +/** + * Set Geometry Options - extrusion/bevel, or plane curvature/segments. + * + * Extrusion is gated on the comp renderer before anything is attempted, because + * the failure mode otherwise is an opaque "property is hidden" error. + */ +export function generateSetGeometryOptions(params: Record): string { + const combined: Record = { ...EXTRUSION_PROPERTIES, ...PLANE_PROPERTIES }; + rejectUnknownKeys(params, combined, LAYER_REF_KEYS, 'geometry'); + + const wantsExtrusion = Object.keys(EXTRUSION_PROPERTIES).some(k => params[k] !== undefined); + const wantsPlane = Object.keys(PLANE_PROPERTIES).some(k => params[k] !== undefined); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + if (wantsExtrusion) { + // Fail fast with an actionable message rather than letting AE say "hidden". + script += 'var rend = comp.renderer;\n'; + script += 'var extrusionOK = false;\n'; + for (let i = 0; i < EXTRUSION_RENDERERS.length; i++) { + script += 'if (rend === "' + EXTRUSION_RENDERERS[i] + '") { extrusionOK = true; }\n'; + } + script += 'if (!extrusionOK) {\n'; + script += ' throw new Error("Extrusion and bevel are unavailable under renderer \\"" + rend + "\\" (the Classic renderer). Call set_comp_renderer with \\"advanced\\" or \\"cinema4d\\" first.");\n'; + script += '}\n'; + script += 'if (!layer.threeDLayer) {\n'; + script += ' throw new Error("Layer is not 3D. Call set_3d_layer with enable3D: true first.");\n'; + script += '}\n'; + script += 'var extrGroup = layer.property("' + GROUP_MATCH_NAMES.extrusion + '");\n'; + script += emitPropertyApplications('extrGroup', EXTRUSION_PROPERTIES, params, 'geometry').script; + } + + if (wantsPlane) { + script += 'var planeGroup = layer.property("' + GROUP_MATCH_NAMES.plane + '");\n'; + script += emitPropertyApplications('planeGroup', PLANE_PROPERTIES, params, 'geometry').script; + } + + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + renderer: 'comp.renderer', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Geometry Options'); +} + +/** + * Set Camera Options on a camera layer. + */ +export function generateSetCameraOptions(params: Record): string { + rejectUnknownKeys(params, CAMERA_PROPERTIES, LAYER_REF_KEYS, 'camera'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var camGroup = layer.property("' + GROUP_MATCH_NAMES.camera + '");\n'; + script += 'if (!camGroup) {\n'; + script += ' throw new Error("Layer is not a camera: " + layer.name);\n'; + script += '}\n'; + script += emitPropertyApplications('camGroup', CAMERA_PROPERTIES, params, 'camera').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Camera Options'); +} + +/** + * Set Light Options on a light layer. + */ +export function generateSetLightOptions(params: Record): string { + rejectUnknownKeys(params, LIGHT_PROPERTIES, LAYER_REF_KEYS, 'light'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var lightGroup = layer.property("' + GROUP_MATCH_NAMES.light + '");\n'; + script += 'if (!lightGroup) {\n'; + script += ' throw new Error("Layer is not a light: " + layer.name);\n'; + script += '}\n'; + script += emitPropertyApplications('lightGroup', LIGHT_PROPERTIES, params, 'light').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Light Options'); +} + +/** + * Read the 3D state of a composition and optionally one layer, so callers can + * see what is actually settable before trying. + */ +export function generateGet3DInfo(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + const hasLayer = params.layerIndex !== undefined || params.layerName !== undefined; + if (hasLayer) { + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + } + + script += 'function readGroup(grp) {\n'; + script += ' if (!grp) { return null; }\n'; + script += ' var vals = {};\n'; + script += ' for (var i = 1; i <= grp.numProperties; i++) {\n'; + script += ' var p = grp.property(i);\n'; + script += ' try { vals[p.matchName] = p.value; } catch (e) { vals[p.matchName] = null; }\n'; + script += ' }\n'; + script += ' return vals;\n'; + script += '}\n'; + + const resultProps: Record = { + compName: 'comp.name', + renderer: 'comp.renderer', + availableRenderers: 'comp.renderers', + supportsExtrusion: 'comp.renderer !== "ADBE Advanced 3d"' + }; + + if (hasLayer) { + script += 'var is3D = layer.threeDLayer ? true : false;\n'; + script += 'var matGroup = layer.property("' + GROUP_MATCH_NAMES.material + '");\n'; + script += 'var camGroup = layer.property("' + GROUP_MATCH_NAMES.camera + '");\n'; + script += 'var lightGroup = layer.property("' + GROUP_MATCH_NAMES.light + '");\n'; + script += 'var extrGroup = layer.property("' + GROUP_MATCH_NAMES.extrusion + '");\n'; + script += 'var planeGroup = layer.property("' + GROUP_MATCH_NAMES.plane + '");\n'; + resultProps.layerName = 'layer.name'; + resultProps.is3D = 'is3D'; + resultProps.materialOptions = 'readGroup(matGroup)'; + resultProps.cameraOptions = 'readGroup(camGroup)'; + resultProps.lightOptions = 'readGroup(lightGroup)'; + resultProps.geometryExtrusion = 'readGroup(extrGroup)'; + resultProps.geometryPlane = 'readGroup(planeGroup)'; + } + + script += generateResultObject(resultProps); + + return script; +} diff --git a/src/ae-integration/scriptGenerator.ts b/src/ae-integration/scriptGenerator.ts index b148938..e1e3e17 100644 --- a/src/ae-integration/scriptGenerator.ts +++ b/src/ae-integration/scriptGenerator.ts @@ -130,6 +130,25 @@ export { SHAPE_MATCH_NAMES } from './generators/shapeGenerators.js'; +// 3D generators +export { + generateSetCompRenderer, + generateSet3DLayer, + generateSetMaterialOptions, + generateSetGeometryOptions, + generateSetCameraOptions, + generateSetLightOptions, + generateGet3DInfo, + RENDERERS, + EXTRUSION_RENDERERS, + MATERIAL_PROPERTIES, + CAMERA_PROPERTIES, + LIGHT_PROPERTIES, + EXTRUSION_PROPERTIES, + PLANE_PROPERTIES, + TRANSFORM_3D_PROPERTIES +} from './generators/threeDGenerators.js'; + // Mask generators export { generateAddMask, diff --git a/src/stdio-server.ts b/src/stdio-server.ts index 2f0af46..d60ec8b 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -436,6 +436,169 @@ const TOOLS = [ }, generator: generators.generateAddShapeOperator }, + { + name: 'set_comp_renderer', + description: 'Set a composition\'s 3D renderer. This is the unlock for extrusion, bevel, environment layers and reflections - all of which are inert under the Classic renderer. NOTE: the internal name "ADBE Advanced 3d" is the CLASSIC renderer, not Advanced 3D, and it is the default for new comps.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + renderer: { + type: 'string', + description: 'classic (no extrusion), advanced (extrusion + shadow colour), cinema4d (extrusion + reflections). A raw "ADBE ..." string is also accepted.', + enum: ['classic', 'advanced', 'cinema4d'] + } + }, + required: ['renderer'] + }, + generator: generators.generateSetCompRenderer + }, + { + name: 'get_3d_info', + description: 'Read a composition\'s renderer and available renderers, and optionally a layer\'s 3D state with current Material, Camera, Light and Geometry values. Use this before setting 3D properties to see what is actually available.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' } + } + }, + generator: generators.generateGet3DInfo + }, + { + name: 'set_3d_layer', + description: 'Enable or disable 3D on a layer and set its 3D transform. Position is set through the combined property because the separated X/Y/Z Position properties are read-only unless Separate Dimensions is enabled.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + enable3D: { type: 'boolean', description: 'Turn the layer\'s 3D switch on or off' }, + anchorPoint: { type: 'array', items: { type: 'number' }, description: '[x, y, z]' }, + position: { type: 'array', items: { type: 'number' }, description: '[x, y, z]' }, + scale: { type: 'array', items: { type: 'number' }, description: '[x, y, z] percent' }, + orientation: { type: 'array', items: { type: 'number' }, description: '[x, y, z] degrees' }, + rotationX: { type: 'number', description: 'Degrees' }, + rotationY: { type: 'number', description: 'Degrees' }, + rotationZ: { type: 'number', description: 'Degrees' }, + opacity: { type: 'number', description: 'Percent' } + } + }, + generator: generators.generateSet3DLayer + }, + { + name: 'set_material_options', + description: 'Set Material Options on a 3D layer - shadows, lights, ambient/diffuse/specular, metal, reflection and transparency. Properties refused by the current renderer are reported in "failed" rather than aborting the call.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + castsShadows: { type: 'boolean' }, + lightTransmission: { type: 'number', description: 'Percent' }, + acceptsShadows: { type: 'boolean' }, + acceptsLights: { type: 'boolean' }, + shadowColor: { type: 'object', description: 'Colour (0-1 range). Advanced renderer only.' }, + appearsInReflections: { type: 'boolean', description: 'CINEMA 4D renderer only' }, + ambient: { type: 'number', description: 'Percent' }, + diffuse: { type: 'number', description: 'Percent' }, + specularIntensity: { type: 'number', description: 'Percent' }, + specularShininess: { type: 'number', description: 'Percent' }, + metal: { type: 'number', description: 'Percent' }, + reflectionIntensity: { type: 'number', description: 'Percent. CINEMA 4D renderer only.' }, + reflectionSharpness: { type: 'number', description: 'Percent' }, + reflectionRolloff: { type: 'number', description: 'Percent' }, + transparency: { type: 'number', description: 'Percent' }, + transparencyRolloff: { type: 'number', description: 'Percent' }, + indexOfRefraction: { type: 'number' } + } + }, + generator: generators.generateSetMaterialOptions + }, + { + name: 'set_geometry_options', + description: 'Set Geometry Options on a 3D layer. Extrusion and bevel apply to text and shape layers only and REQUIRE the advanced or cinema4d renderer - the call fails fast with an actionable message otherwise, rather than surfacing After Effects\' opaque "property is hidden" error. Curvature and segments apply to the plane geometry group.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + extrusionDepth: { type: 'number', description: 'Text and shape layers only; needs advanced/cinema4d renderer' }, + bevelStyle: { type: 'number', description: '1 none, 2 angular, 3 concave, 4 convex' }, + bevelDirection: { type: 'number' }, + bevelDepth: { type: 'number' }, + holeBevelDepth: { type: 'number', description: 'Percent' }, + curvature: { type: 'number', description: 'Plane geometry curvature, percent' }, + segments: { type: 'number', description: 'Plane geometry subdivisions' } + } + }, + generator: generators.generateSetGeometryOptions + }, + { + name: 'set_camera_options', + description: 'Set Camera Options on a camera layer - zoom, depth of field, focus, aperture, blur, and the iris/highlight controls', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + zoom: { type: 'number', description: 'Pixels' }, + depthOfField: { type: 'boolean' }, + focusDistance: { type: 'number', description: 'Pixels' }, + aperture: { type: 'number', description: 'Pixels' }, + blurLevel: { type: 'number', description: 'Percent' }, + focusAreaWidth: { type: 'number' }, + nearFarBlurLevel: { type: 'number' }, + irisShape: { type: 'number', description: 'Iris shape index' }, + irisRotation: { type: 'number', description: 'Degrees' }, + irisRoundness: { type: 'number', description: 'Percent' }, + irisAspectRatio: { type: 'number' }, + irisDiffractionFringe: { type: 'number' }, + highlightGain: { type: 'number' }, + highlightThreshold: { type: 'number' }, + highlightSaturation: { type: 'number' } + } + }, + generator: generators.generateSetCameraOptions + }, + { + name: 'set_light_options', + description: 'Set Light Options on a light layer - intensity, colour, cone angle and feather, falloff, shadows, and the environment-light background controls', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + intensity: { type: 'number', description: 'Percent' }, + color: { type: 'object', description: 'Colour (0-1 range)' }, + coneAngle: { type: 'number', description: 'Degrees; spot lights' }, + coneFeather: { type: 'number', description: 'Percent; spot lights' }, + falloff: { type: 'number', description: '1 none, 2 smooth, 3 inverse square clamped' }, + radius: { type: 'number' }, + falloffDistance: { type: 'number' }, + castsShadows: { type: 'boolean' }, + shadowDarkness: { type: 'number', description: 'Percent' }, + shadowDiffusion: { type: 'number', description: 'Pixels' }, + backgroundVisible: { type: 'boolean', description: 'Environment lights' }, + backgroundOpacity: { type: 'number', description: 'Environment lights' }, + backgroundBlur: { type: 'number', description: 'Environment lights' } + } + }, + generator: generators.generateSetLightOptions + }, { name: 'add_mask', description: 'Add a mask to a layer, optionally with a bezier path. Tangents are RELATIVE to their own vertex.', From 63ea03c1758cb783468a7e60a5107e585c02e1f9 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:04:39 -0400 Subject: [PATCH 07/25] fix(keyframes): stop destroying keyframes on bad input, and fix spatial easing Found by a full regression sweep over every non-destructive tool. Data loss in offset_keyframes and scale_keyframe_timing ------------------------------------------------------ Both removed EVERY key first and then re-added only the ones they could place, silently dropping the rest while still reporting success. - scale_keyframe_timing with scale missing -> scaleFactor undefined -> every new time NaN -> `NaN >= 0` false -> all keys removed, none re-added, response "keyframesScaled: 2". Passing `factor` instead of `scale` - an easy slip - wiped the animation. - scale 0 collapsed every key onto one frame. - offset_keyframes silently deleted any key pushed before time 0. Both now compute and validate every new time BEFORE removing anything, and refuse with "Nothing was changed" if a key would be lost - landing before 0, or (for scale) two keys merging onto the same frame. Numeric params are validated in TypeScript first. The reported count is now the number of keys actually written, not the number that were read. Spatial properties take one temporal ease ----------------------------------------- set_keyframe_advanced, apply_easy_ease and set_temporal_ease all counted TwoD_SPATIAL / ThreeD_SPATIAL as 2 or 3 dimensions and built that many KeyframeEase objects. Spatial properties (Position, Anchor Point) take exactly one, so all three failed on Position - easy easing Position being apply_easy_ease's main use - with "Value array does not have 1 elements". Only non-spatial multi-dimensional properties such as Scale get 2 or 3. copy_keyframes runaway ---------------------- The loop read sourceProp.numKeys live. Copying onto the same property grew the bound with every key added: a 2-key copy produced 4,321 keys. Source keys are now snapshotted before any are written. Also validates sourceProperty, which previously crashed in TypeScript with "Cannot read properties of undefined (reading 'split')". Verified against AE 26.5: all eight bad-input cases are refused with the keyframes byte-identical afterwards, and valid input still produces the expected times. Co-Authored-By: Claude Opus 5 --- .../generators/keyframeGenerators.ts | 119 +++++++++++++----- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/src/ae-integration/generators/keyframeGenerators.ts b/src/ae-integration/generators/keyframeGenerators.ts index 7795107..35a7c92 100644 --- a/src/ae-integration/generators/keyframeGenerators.ts +++ b/src/ae-integration/generators/keyframeGenerators.ts @@ -101,9 +101,9 @@ export function generateSetKeyframeAdvanced(params: { if (params.inEase || params.outEase) { // Determine number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -171,9 +171,9 @@ export function generateApplyEasyEase(params: { // Get number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -235,9 +235,9 @@ export function generateSetTemporalEase(params: { // Get number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -284,6 +284,9 @@ export function generateOffsetKeyframes(params: { script += generateLayerAccess('comp', params.layerIndex, params.layerName); script += generatePropertyAccess('layer', params.property); + if (typeof params.offset !== 'number' || !isFinite(params.offset)) { + throw new Error('offset must be a finite number of seconds'); + } script += 'var offset = ' + params.offset + ';\n'; script += 'if (prop.numKeys === 0) {\n'; @@ -303,25 +306,35 @@ export function generateOffsetKeyframes(params: { script += ' });\n'; script += '}\n'; - // Remove all keyframes + // Compute and validate every new time BEFORE touching the property. This + // used to remove all keys first and silently drop any that landed before + // 0, destroying animation while reporting success. + script += 'var earliest = null;\n'; + script += 'for (var i = 0; i < keyData.length; i++) {\n'; + script += ' keyData[i].newTime = keyData[i].time + offset;\n'; + script += ' if (earliest === null || keyData[i].newTime < earliest) { earliest = keyData[i].newTime; }\n'; + script += '}\n'; + script += 'if (earliest < 0) {\n'; + script += ' throw new Error("Offset " + offset + " would move a keyframe before time 0 (it would land at " + earliest + "s). Nothing was changed.");\n'; + script += '}\n'; + + // Safe to rewrite now - no key can be lost. script += 'while (prop.numKeys > 0) {\n'; script += ' prop.removeKey(1);\n'; script += '}\n'; - // Re-add keyframes at new times + script += 'var keysWritten = 0;\n'; script += 'for (var i = 0; i < keyData.length; i++) {\n'; - script += ' var newTime = keyData[i].time + offset;\n'; - script += ' if (newTime >= 0) {\n'; - script += ' var keyIndex = prop.addKey(newTime);\n'; - script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; - script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; - script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; - script += ' }\n'; + script += ' var keyIndex = prop.addKey(keyData[i].newTime);\n'; + script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; + script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; + script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; + script += ' keysWritten++;\n'; script += '}\n'; script += generateResultObject({ success: 'true', - keyframesMoved: 'keyData.length', + keyframesMoved: 'keysWritten', offset: 'offset' }); @@ -347,6 +360,12 @@ export function generateScaleKeyframeTiming(params: { script += generatePropertyAccess('layer', params.property); const anchor = params.anchorTime !== undefined ? params.anchorTime : 0; + if (typeof params.scale !== 'number' || !isFinite(params.scale) || params.scale <= 0) { + throw new Error('scale must be a finite number greater than 0 (2 doubles the timing, 0.5 halves it)'); + } + if (params.anchorTime !== undefined && (typeof params.anchorTime !== 'number' || !isFinite(params.anchorTime))) { + throw new Error('anchorTime must be a finite number of seconds'); + } script += 'var scaleFactor = ' + params.scale + ';\n'; script += 'var anchorTime = ' + anchor + ';\n'; @@ -367,25 +386,42 @@ export function generateScaleKeyframeTiming(params: { script += ' });\n'; script += '}\n'; - // Remove all keyframes + // Compute and validate every new time BEFORE touching the property. This + // used to remove all keys first and re-add only those it could place, so + // a missing scale (NaN) or a key pushed before 0 wiped the animation while + // reporting success. + script += 'var seenFrames = {};\n'; + script += 'for (var i = 0; i < keyData.length; i++) {\n'; + script += ' var nt = anchorTime + (keyData[i].time - anchorTime) * scaleFactor;\n'; + script += ' if (nt < 0) {\n'; + script += ' throw new Error("Scaling by " + scaleFactor + " around " + anchorTime + "s would move a keyframe before time 0 (to " + nt + "s). Nothing was changed.");\n'; + script += ' }\n'; + // Keys that land on the same frame merge into one, which also loses data. + script += ' var frameNum = Math.round(nt * comp.frameRate);\n'; + script += ' if (seenFrames[frameNum]) {\n'; + script += ' throw new Error("Scaling by " + scaleFactor + " would merge two keyframes onto frame " + frameNum + ". Nothing was changed.");\n'; + script += ' }\n'; + script += ' seenFrames[frameNum] = true;\n'; + script += ' keyData[i].newTime = nt;\n'; + script += '}\n'; + + // Safe to rewrite now - no key can be lost. script += 'while (prop.numKeys > 0) {\n'; script += ' prop.removeKey(1);\n'; script += '}\n'; - // Re-add keyframes at scaled times + script += 'var keysWritten = 0;\n'; script += 'for (var i = 0; i < keyData.length; i++) {\n'; - script += ' var newTime = anchorTime + (keyData[i].time - anchorTime) * scaleFactor;\n'; - script += ' if (newTime >= 0) {\n'; - script += ' var keyIndex = prop.addKey(newTime);\n'; - script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; - script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; - script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; - script += ' }\n'; + script += ' var keyIndex = prop.addKey(keyData[i].newTime);\n'; + script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; + script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; + script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; + script += ' keysWritten++;\n'; script += '}\n'; script += generateResultObject({ success: 'true', - keyframesScaled: 'keyData.length', + keyframesScaled: 'keysWritten', scale: 'scaleFactor' }); @@ -465,6 +501,11 @@ export function generateCopyKeyframes(params: { targetProperty?: string; timeOffset?: number; }): string { + // Without this, a missing sourceProperty crashes in TypeScript with + // "Cannot read properties of undefined (reading 'split')". + if (!params.sourceProperty) { + throw new Error('copy_keyframes requires sourceProperty'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); @@ -492,14 +533,30 @@ export function generateCopyKeyframes(params: { script += '}\n'; // Copy keyframes + // Snapshot every source key BEFORE writing any. The loop used to read + // sourceProp.numKeys live, so copying onto the same property grew the + // bound with every key added - a 2-key copy produced 4,321 keys - and the + // added keys also shifted the source indices mid-loop. + script += 'var srcCount = sourceProp.numKeys;\n'; + script += 'var snap = [];\n'; + script += 'for (var i = 1; i <= srcCount; i++) {\n'; + script += ' snap.push({\n'; + script += ' time: sourceProp.keyTime(i),\n'; + script += ' value: sourceProp.keyValue(i),\n'; + script += ' inInterp: sourceProp.keyInInterpolationType(i),\n'; + script += ' outInterp: sourceProp.keyOutInterpolationType(i),\n'; + script += ' inEase: sourceProp.keyInTemporalEase(i),\n'; + script += ' outEase: sourceProp.keyOutTemporalEase(i)\n'; + script += ' });\n'; + script += '}\n'; script += 'var keysCopied = 0;\n'; - script += 'for (var i = 1; i <= sourceProp.numKeys; i++) {\n'; - script += ' var newTime = sourceProp.keyTime(i) + ' + timeOffset + ';\n'; + script += 'for (var s = 0; s < snap.length; s++) {\n'; + script += ' var newTime = snap[s].time + ' + timeOffset + ';\n'; script += ' if (newTime >= 0) {\n'; script += ' var keyIndex = targetProp.addKey(newTime);\n'; - script += ' targetProp.setValueAtKey(keyIndex, sourceProp.keyValue(i));\n'; - script += ' targetProp.setInterpolationTypeAtKey(keyIndex, sourceProp.keyInInterpolationType(i), sourceProp.keyOutInterpolationType(i));\n'; - script += ' targetProp.setTemporalEaseAtKey(keyIndex, sourceProp.keyInTemporalEase(i), sourceProp.keyOutTemporalEase(i));\n'; + script += ' targetProp.setValueAtKey(keyIndex, snap[s].value);\n'; + script += ' targetProp.setInterpolationTypeAtKey(keyIndex, snap[s].inInterp, snap[s].outInterp);\n'; + script += ' targetProp.setTemporalEaseAtKey(keyIndex, snap[s].inEase, snap[s].outEase);\n'; script += ' keysCopied++;\n'; script += ' }\n'; script += '}\n'; From 21e95b2a52956918068c714b55787b336b988812 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:04:49 -0400 Subject: [PATCH 08/25] fix(generators): object literals parsed as blocks broke four tools get_current_time, navigate_markers, collect_files and import_folder all ended their generated script with a bare object literal: { time: comp.time, frame: Math.round(comp.time * comp.frameRate) }; In statement position a `{` opens a BLOCK, not an object. `time:` parses as a label and the comma is a syntax error, so every call failed with "SyntaxError: Expected: ;" - for every user, on every invocation. get_current_time and navigate_markers were unregistered until recently, which hid it. collect_files and import_folder are registered upstream tools, so they have been failing for everyone. The literal is now parenthesised, assigned to `result`, and restated as the final expression, so it parses and is the value the script returns. (For the mutating tools wrapped in an undo group, that value only reaches the caller once the wrapper stops ending on app.endUndoGroup() - see PR #4.) copy_effects had the same bug and is fixed alongside the other effects changes. Co-Authored-By: Claude Opus 5 --- src/ae-integration/generators/assetGenerators.ts | 10 ++++++---- src/ae-integration/generators/markerGenerators.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ae-integration/generators/assetGenerators.ts b/src/ae-integration/generators/assetGenerators.ts index 824cbd3..ebd75d9 100644 --- a/src/ae-integration/generators/assetGenerators.ts +++ b/src/ae-integration/generators/assetGenerators.ts @@ -57,12 +57,13 @@ export function generateImportFolder(params: { script += 'var rootFolder = app.project.items.addFolder(folder.name);\n'; script += 'importFilesFromFolder(folder, rootFolder);\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' folderId: rootFolder.id,\n'; script += ' folderName: rootFolder.name,\n'; script += ' importedCount: importedItems.length,\n'; script += ' items: importedItems\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Import Folder'); } @@ -271,11 +272,12 @@ export function generateCollectFiles(params: { script += 'var projectFile = new File(outputFolder.fsName + "/" + (app.project.file ? app.project.file.name : "collected_project.aep"));\n'; script += 'app.project.save(projectFile);\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' outputPath: outputFolder.fsName,\n'; script += ' collectedCount: collectedFiles.length,\n'; script += ' files: collectedFiles\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Collect Files'); } diff --git a/src/ae-integration/generators/markerGenerators.ts b/src/ae-integration/generators/markerGenerators.ts index c610639..1be2172 100644 --- a/src/ae-integration/generators/markerGenerators.ts +++ b/src/ae-integration/generators/markerGenerators.ts @@ -250,11 +250,12 @@ export function generateGetCurrentTime(params: { script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); - script += '{\n'; + script += 'var result = ({\n'; script += ' time: comp.time,\n'; script += ' frame: Math.round(comp.time * comp.frameRate),\n'; script += ' frameRate: comp.frameRate\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return script; } @@ -381,11 +382,12 @@ export function generateNavigateMarkers(params: { script += 'var markerTime = markerProp.keyTime(targetIndex);\n'; script += 'comp.time = markerTime;\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' markerIndex: targetIndex,\n'; script += ' time: markerTime,\n'; script += ' comment: markerProp.keyValue(targetIndex).comment\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return script; } From 70a1baf78be7cdacfde5f968bc52301bcccdb171 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:04:50 -0400 Subject: [PATCH 09/25] fix(effects): reorder_effects stale reference, copy_effects syntax error reorder_effects read effect.name AFTER effect.moveTo(). Moving a property invalidates the reference, so every call threw "ReferenceError: Object is invalid" - after the move had already happened. The name is now read first, and both indices are range-checked up front, so an out-of-range move says so instead of AE's "parent property has only 1 child properties". Also looks the group up by match name (ADBE Effect Parade) rather than the English display name. copy_effects ended with a bare object literal, which parses as a block and failed with "SyntaxError: Expected: ;". Same root cause and fix as the marker and asset generators. Co-Authored-By: Claude Opus 5 --- .../generators/effectsGenerators.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/ae-integration/generators/effectsGenerators.ts b/src/ae-integration/generators/effectsGenerators.ts index 3f0385e..4cc774f 100644 --- a/src/ae-integration/generators/effectsGenerators.ts +++ b/src/ae-integration/generators/effectsGenerators.ts @@ -450,16 +450,21 @@ export function generateReorderEffects(params: { script += generateCompAccess(params.compId, params.compName); script += generateLayerAccess('comp', params.layerIndex, params.layerName); - script += 'var effects = layer.property("Effects");\n'; - script += 'var effect = effects(' + params.effectIndex + ');\n'; - script += 'if (!effect) {\n'; - script += ' throw new Error("Effect not found at index: ' + params.effectIndex + '");\n'; + script += 'var effects = layer.property("ADBE Effect Parade");\n'; + script += 'if (' + params.effectIndex + ' < 1 || ' + params.effectIndex + ' > effects.numProperties) {\n'; + script += ' throw new Error("Effect index ' + params.effectIndex + ' out of range; layer has " + effects.numProperties + " effect(s)");\n'; + script += '}\n'; + script += 'if (' + params.newIndex + ' < 1 || ' + params.newIndex + ' > effects.numProperties) {\n'; + script += ' throw new Error("New index ' + params.newIndex + ' out of range; layer has " + effects.numProperties + " effect(s)");\n'; script += '}\n'; + script += 'var effect = effects.property(' + params.effectIndex + ');\n'; + // moveTo() invalidates this reference, so read the name first. + script += 'var movedEffectName = effect.name;\n'; script += 'effect.moveTo(' + params.newIndex + ');\n'; script += generateResultObject({ - effectName: 'effect.name', + effectName: 'movedEffectName', newIndex: String(params.newIndex) }); @@ -523,10 +528,11 @@ export function generateCopyEffects(params: { script += ' }\n'; script += '}\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' copiedEffects: copiedEffects,\n'; script += ' count: copiedEffects.length\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Copy Effects'); } From 94306d79ef783285d9753baa9bb9f19d04d78d76 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:04:57 -0400 Subject: [PATCH 10/25] fix(layers): polystar star/polygon types were reversed Polystar Type is 1 = STAR, 2 = POLYGON. Verified directly: the default is 1, and Inner Radius is only settable under 1. The generator had them backwards: - shape "star" set type 2 (polygon), then set Inner Radius, which is hidden on a polygon -> "property or a parent property is hidden". - shape "polygon" set type 1 and silently drew a STAR, with no error. Also switches add_camera_layer and add_light_layer from English display names ("Camera Options", "Zoom", "Light Options", "Color", "Intensity") to match names, as already done for add_shape_layer. Co-Authored-By: Claude Opus 5 --- src/ae-integration/generators/layerGenerators.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ae-integration/generators/layerGenerators.ts b/src/ae-integration/generators/layerGenerators.ts index 7ecadb2..1d77cab 100644 --- a/src/ae-integration/generators/layerGenerators.ts +++ b/src/ae-integration/generators/layerGenerators.ts @@ -254,12 +254,14 @@ export function generateAddShapeLayer(params: { script += 'ellipse.property("ADBE Vector Ellipse Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; } else if (shape === 'polygon') { script += 'var poly = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'poly.property("ADBE Vector Star Type").setValue(1);\n'; // polygon + // Polystar Type is 1 = STAR, 2 = POLYGON (verified: Inner Radius is only + // settable under 1). These were reversed, so 'polygon' silently drew a star. + script += 'poly.property("ADBE Vector Star Type").setValue(2);\n'; // polygon script += 'poly.property("ADBE Vector Star Points").setValue(' + (params.points || 6) + ');\n'; script += 'poly.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; } else if (shape === 'star') { script += 'var star = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'star.property("ADBE Vector Star Type").setValue(2);\n'; // star + script += 'star.property("ADBE Vector Star Type").setValue(1);\n'; // star script += 'star.property("ADBE Vector Star Points").setValue(' + (params.points || 5) + ');\n'; script += 'star.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; script += 'star.property("ADBE Vector Star Inner Radius").setValue(' + (params.innerRadius || 50) + ');\n'; @@ -379,7 +381,7 @@ export function generateAddCameraLayer(params: { } if (params.zoom) { - script += 'layer.property("Camera Options").property("Zoom").setValue(' + params.zoom + ');\n'; + script += 'layer.property("ADBE Camera Options Group").property("ADBE Camera Zoom").setValue(' + params.zoom + ');\n'; } script += generateResultObject({ @@ -414,10 +416,10 @@ export function generateAddLightLayer(params: { script += 'layer.lightType = ' + generateLightType(lightType) + ';\n'; if (params.color) { - script += 'layer.property("Light Options").property("Color").setValue(' + colorToES3(params.color) + ');\n'; + script += 'layer.property("ADBE Light Options Group").property("ADBE Light Color").setValue(' + colorToES3(params.color) + ');\n'; } if (params.intensity !== undefined) { - script += 'layer.property("Light Options").property("Intensity").setValue(' + params.intensity + ');\n'; + script += 'layer.property("ADBE Light Options Group").property("ADBE Light Intensity").setValue(' + params.intensity + ');\n'; } script += generateResultObject({ From 02f0d0c7a96508907f043b7246a4c2995f381df7 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:04:57 -0400 Subject: [PATCH 11/25] fix(templates): create_logo_reveal fails clearly when no logo is given create_logo_reveal animates an existing footage item. With neither logoItemId nor logoItemName, neither branch declared logoItem and the script died with a bare "ReferenceError: logoItem is undefined". It now says what is missing before any script is generated. Co-Authored-By: Claude Opus 5 --- src/ae-integration/generators/templateGenerators.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ae-integration/generators/templateGenerators.ts b/src/ae-integration/generators/templateGenerators.ts index 09bb3f7..dc6e7e8 100644 --- a/src/ae-integration/generators/templateGenerators.ts +++ b/src/ae-integration/generators/templateGenerators.ts @@ -403,6 +403,11 @@ export function generateCreateLogoReveal(params: { duration?: number; backgroundColor?: { r: number; g: number; b: number }; }): string { + // Without this, neither branch below declares logoItem and the script dies + // with a bare "ReferenceError: logoItem is undefined". + if (params.logoItemId === undefined && !params.logoItemName) { + throw new Error('create_logo_reveal animates an existing logo item: pass logoItemId or logoItemName'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); From e1a450897b5203d5f70c21641276160aed932442 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:19:49 -0400 Subject: [PATCH 12/25] docs(skill): correct the ae-mcp skill against the real tools The bundled skill documented calls that fail or misbehave. Every claim below was checked against the tool schemas and generator code, and the path, effect, blend-mode and example calls were run live in AE 26.5. Wrong, and failing: - Nested property paths were written with dots ("Effects.Gaussian Blur.Blurriness"). The server splits on "/", so a dotted path is looked up as one literal name: "Property not found". - Shape examples used group "Shape 1". Groups created by these tools are "Group 1"; operators sit under "Contents/Group 1/Contents/...". - apply_expression_template("randomize", ...) - randomize is a text animator type, not an expression template. - blendMode "Screen" - the value is passed through as an AE constant, so it must be SCREEN; "Screen" errors with "Value is undefined". Wrong, and silently ineffective: - springy was given `frequency`; it takes mass, stiffness and damping. - The parallax example relied on link_properties' offset, which is additive and cannot scale motion. Now uses a multiplier expression. - modify_layer(motionBlur=...) - no such parameter. - "Duplicate layer" and time remapping - no such tools. The description no longer triggers on time remapping. - create_logo_reveal style "particle" is listed but has no implementation; the skill now says to avoid it. Also: - Recommends the camelCase path shortcuts (position, rotation, sourceText), which resolve to match names and survive non-English installs. Leaves out coneFeather, castsShadows and pointOfInterest, whose shortcuts point at the wrong properties. - Covers the new shape path, operator, mask, 3D and render queue tools, including the renderer capability matrix, gradient colours being unscriptable, render() blocking, and the output module choosing the file type. - Documents known issues: precompose_layers moves layers then errors, and delete_composition leaves its solids behind. - Tells the model to ask before project-wide operations. - All examples use named parameters matching the schemas. Co-Authored-By: Claude Opus 5 --- SKILL/SKILL.md | 371 ++++++++++++++++------------------------------ SKILL/examples.md | 339 ++++++++++++++++++++++-------------------- 2 files changed, 306 insertions(+), 404 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 01439f2..2be4a28 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -1,287 +1,170 @@ --- name: ae-mcp -description: After Effects automation through Model Context Protocol. Use when users request creating, modifying, or animating content in Adobe After Effects, including compositions, layers, keyframes, effects, expressions, text animations, lower thirds, title cards, logo reveals, or any motion graphics tasks. Trigger on phrases like "create in After Effects", "animate this", "make a motion graphic", "add keyframes", "apply effects", or when users mention AE-specific concepts like compositions, precomps, expressions, or time remapping. +description: Drive Adobe After Effects through the ae-mcp tools. Use when the user wants to create, edit, animate or render anything in After Effects - compositions, text, shape layers and bezier paths, trim paths and other shape operators, masks, keyframes and easing, expressions, effects, 3D layers with cameras, lights and extrusion, lower thirds, title cards, transitions, logo reveals, markers, and the render queue or Adobe Media Encoder. Trigger on phrases like "in After Effects", "make a motion graphic", "animate this", "add keyframes", "draw on a line", "mask this", "extrude the text", "render this comp", or AE terms like comp, precomp, expression, trim paths, render queue. --- -# After Effects MCP Skill +# After Effects MCP -## Overview +These tools send ExtendScript to After Effects through a CEP panel. **The AE-MCP panel must be open** (Window > Extensions > AE-MCP). If every call times out, ask the user to open it. -This skill enables automation of Adobe After Effects through MCP tools. It covers project management, composition creation, layer manipulation, animation, effects, and expressions. +For worked recipes, read `examples.md` in this skill. -## Critical Property Path Formats +## Before you act -After Effects properties require exact naming. Common patterns: +- **Ask before calling tools that change the open project as a whole**: `save_project`, `close_project`, `open_project`, `create_project`, `reduce_project`, `collect_files`, `organize_project_items`, `import_footage`, `import_folder`, `replace_footage`. The user usually has real work open. +- **Look before you edit.** Call `list_compositions`, `list_layers` or `get_layer_info` rather than guessing names. +- **Every tool call is one undo step**, named for the operation ("Undo Create Lower Third"). Prefer a few purposeful calls over many tiny ones, so the user can undo cleanly. +- Times are in **seconds**. Colours are **0-1** RGB objects: `{"r": 1, "g": 0.5, "b": 0}`. -**Transform properties:** -- Position: `"Position"` -- Scale: `"Scale"` -- Rotation: `"Rotation"` or `"Z Rotation"` -- Opacity: `"Opacity"` -- Anchor Point: `"Anchor Point"` +## Property paths -**Text properties:** -- Source Text: `"Source Text"` -- For text animators: Use relative paths like `"Opacity"` within animator context +Keyframe, expression and effect-property tools take a `property` string. -**Shape layer properties:** -- Position within shape group: `"Contents.Shape 1.Transform.Position"` -- Scale within shape group: `"Contents.Shape 1.Transform.Scale"` +**1. Prefer the camelCase shortcuts.** They resolve to internal match names, so they also work on non-English installs: -**Effect properties:** -- Effect parameter: `"Effects.Gaussian Blur.Blurriness"` +| Shortcut | Property | +|---|---| +| `position`, `anchorPoint`, `scale`, `opacity` | Transform | +| `rotation` | Rotation (Z rotation on 3D layers) | +| `rotationX`, `rotationY`, `rotationZ` | 3D rotation | +| `sourceText` | Text layer Source Text | +| `audioLevels` | Audio levels | -## Value Formats by Property Type +English display names (`"Position"`, `"Rotation"`, `"Source Text"`) also work on an English install. -Different properties require specific value formats: - -**Position**: `{"x": 960, "y": 540}` or `[960, 540]` -**Scale**: `[100, 100]` (percentages) -**Rotation**: `45` (degrees) -**Opacity**: `100` (percentage, 0-100) -**Color (RGB 0-1 range)**: `{"r": 1.0, "g": 0.5, "b": 0.0}` -**Source Text**: String value - -## Common Workflows - -### 1. Creating a Basic Animated Scene - -``` -1. Create project (optional name) -2. Create composition with specs (width, height, duration, frameRate) -3. Add layers (solid, shape, text, AV) -4. Set initial properties (position, scale, opacity) -5. Add keyframes at different times -6. Apply easing for smooth motion -7. Save project -``` - -### 2. Text Animation Pattern +**2. For nested properties, separate levels with `/` - never `.`.** ``` -1. Add text layer with content -2. Set text properties (fontSize, color, position, justification) -3. Use create_text_animator for preset animations: - - typewriter - - fadeInChars - - scaleInChars - - slideInChars - - randomize - - wave -4. Or manually add keyframes to text properties +Effects/Gaussian Blur/Blurriness +Effects/Gradient Ramp/Start Color +Contents/Group 1/Transform/Position +Contents/Group 1/Contents/Trim Paths 1/End ``` -### 3. Effect Application Pattern - -``` -1. Apply effect to layer using apply_effect -2. Modify effect properties using modify_effect_properties -3. Or use apply_effect_template for preset effects: - - gaussianBlur, directionalBlur, glassBlur - - curves, colorBalance, brightnessContrast, vibrance - - glow, dropShadow, vignette - - cinematicLook, vhsRetro, neonGlow, filmGrain - - chromaticAberration, duotone -``` - -### 4. Expression-Based Animation - -``` -1. Set expression on property using set_expression -2. Common expression templates available via apply_expression_template: - - wiggle, wiggleSmooth, wiggleFadeIn, wiggleFadeOut - - loopCycle, loopPingpong, loopOffset, loopContinue - - time, clock, countdown, frameNumber - - matchPosition, offsetPosition, inverseRotation - - followPath, bounce, inertia, overshoot, springy -3. Link properties between layers using link_properties -``` +A dotted path such as `Effects.Gaussian Blur.Blurriness` is looked up as one property with that literal name and fails with "Property not found". -## Key Tool Behaviors and Gotchas +**3. Shape groups created by these tools are named `Group 1`**, not `Shape 1` (that name only appears on shapes drawn by hand with the Pen tool). Operators live *inside* the group's `Contents`, so Trim Paths is `Contents/Group 1/Contents/Trim Paths 1/...`. -### Keyframe Setting +For camera, light and material properties, use the dedicated `set_*_options` tools instead of paths - they report which properties the current renderer refused. -**set_keyframe** - Basic keyframe setting: -- Use for simple keyframes without easing -- Value format must match property type -- Time in seconds (not frames) +## Value formats -**set_keyframe_advanced** - With easing control: -- inType/outType: `LINEAR`, `BEZIER`, `HOLD` -- inEase/outEase: `{speed: number, influence: number}` -- Use for smooth, professional motion +| Property | Format | +|---|---| +| Position | `[960, 540]`, `[x, y, z]` on 3D layers, or `{"x": 960, "y": 540}` | +| Scale | `[100, 100]` (percent) | +| Rotation | `45` (degrees) | +| Opacity | `100` (0-100) | +| Shape fill / stroke colour | `{"r", "g", "b"}` | +| Effect colour parameter via `set_keyframe` | `[r, g, b, a]`, 0-1 | -**apply_easy_ease** - Quick easing application: -- Apply to existing keyframes -- Type: `IN`, `OUT`, `BOTH` -- Can target specific keyframe by index or all keyframes +**Enum values are After Effects constants, in capitals.** `modify_layer` `blendMode` takes `NORMAL`, `SCREEN`, `MULTIPLY`, `ADD`, `OVERLAY` and so on - `"Screen"` fails with "Value is undefined". Text `justification` is `LEFT` / `CENTER` / `RIGHT`; `apply_easy_ease` `type` is `IN` / `OUT` / `BOTH`. -### Layer References +## Tool map -Layers can be identified by: -- `layerName`: Preferred for clarity -- `layerIndex`: 1-based (1 = top layer) +| Area | Tools | +|---|---| +| Project | `get_project_info`, `list_project_items`, `find_missing_footage`, `set_proxy`, `remove_proxy`, plus the project-wide tools listed under "Before you act" | +| Compositions | `create_composition`, `modify_composition`, `duplicate_composition`, `delete_composition`, `list_compositions`, `get_composition_info`, `get_comp_report`, `set_active_composition`, `set_work_area`, `render_frame` | +| Layers | `add_solid_layer`, `add_text_layer`, `add_text_layer_advanced`, `add_shape_layer`, `add_null_layer`, `add_adjustment_layer`, `add_camera_layer`, `add_light_layer`, `add_av_layer`, `modify_layer`, `delete_layer`, `list_layers`, `get_layer_info`, `precompose_layers` (see Known issues) | +| Shape paths | `create_path`, `get_path`, `set_path_keyframes`, `add_shape_operator` | +| Masks | `add_mask`, `list_masks`, `get_mask_path`, `set_mask_path`, `set_mask_keyframes`, `set_mask_properties`, `delete_mask` | +| 3D | `set_comp_renderer`, `get_3d_info`, `set_3d_layer`, `set_material_options`, `set_geometry_options`, `set_camera_options`, `set_light_options` | +| Keyframes | `set_keyframe`, `set_keyframe_advanced`, `get_keyframes`, `apply_easy_ease`, `set_temporal_ease`, `offset_keyframes`, `scale_keyframe_timing`, `reverse_keyframes`, `copy_keyframes` | +| Expressions | `set_expression`, `get_expression`, `remove_expression`, `enable_expression`, `batch_set_expressions`, `add_expression_control`, `apply_expression_template`, `link_properties` | +| Effects | `apply_effect`, `apply_effect_template`, `modify_effect_properties`, `remove_effect`, `reorder_effects`, `copy_effects`, `list_effects` | +| Templates | `create_lower_third`, `create_title_card`, `create_transition`, `create_logo_reveal`, `create_text_animator` | +| Markers and time | `add_composition_marker`, `add_layer_marker`, `get_markers`, `delete_marker`, `get_current_time`, `set_current_time`, `snap_to_marker`, `get_nearest_marker`, `navigate_markers` | +| Render | `add_to_render_queue`, `list_render_queue`, `list_render_templates`, `set_render_queue_item`, `remove_from_render_queue`, `control_render`, `queue_in_ame` | -When modifying layers, always verify layer exists first via list_layers or get_layer_info. +**Not available** - say so rather than improvising: duplicating a layer, time remapping, the motion-blur switch, track mattes, layer styles, and setting gradient colours. -### Composition Management +## Keyframes -**Creating compositions:** -- Specify complete dimensions and timing -- backgroundColor uses 0-1 RGB values -- frameRate commonly: 24, 30, 60 +- `set_keyframe` - value at a time. `set_keyframe_advanced` adds `inType` / `outType` (`LINEAR`, `BEZIER`, `HOLD`) and `inEase` / `outEase` as `{"speed": 0, "influence": 33}`. +- `apply_easy_ease` - `keyframeIndex` for one key, omit it for all. Works on Position. +- `set_temporal_ease` - `keyframeIndex`, `inSpeed`, `inInfluence`, `outSpeed`, `outInfluence`. +- `offset_keyframes` - `offset` in seconds. Refuses, changing nothing, if any key would move before 0. +- `scale_keyframe_timing` - **`scale`** (must be > 0; 2 doubles the timing) and optional `anchorTime`. Refuses if keys would move before 0 or merge onto one frame. +- `copy_keyframes` - `sourceLayerName`, `sourceProperty`, `targetLayerName`, `targetProperty`, `timeOffset`. -**Modifying compositions:** -- Can change any parameter after creation -- Changes affect all nested instances +If one of these refuses with "Nothing was changed", the keyframes are untouched - fix the input and retry. -### Shape Layer Limitations +## Shape layers and paths -Shape layers have complex property hierarchies: -- Must navigate through Contents group -- Transform properties nested within shape groups -- Some properties may be hidden initially +- `add_shape_layer` - `shape`: `rectangle` (supports `roundness`), `ellipse`, `polygon`, `star` (`points`, `outerRadius`, `innerRadius`). +- `create_path` - a real editable bezier path. `vertices` are `[x, y]` points relative to the layer, which the tool places at the comp centre. **`inTangents` / `outTangents` are relative to their own vertex**, not absolute. Omit them for corner points. `closed: false` for an open line. +- `set_path_keyframes` - each keyframe carries its own full vertex list. Keep the vertex count the same between keys for clean morphing. +- `add_shape_operator` - `operator` is one of `trimPaths`, `repeater`, `roundCorners`, `zigZag`, `twist`, `puckerBloat`, `offsetPaths`, `wigglePaths`, `wiggleTransform`, `mergePaths`, `gradientFill`, `gradientStroke`. Set values with friendly keys, for example `{"start": 0, "end": 50}` for trim paths or `{"copies": 6}` for a repeater. To animate an operator, keyframe it through its path (see Property paths). +- **Gradient colour stops cannot be set by script.** After Effects exposes them as read-only, so gradients render with default colours. Tell the user to set the colours by hand. -When shape operations fail with "property is hidden" error, the layer structure may need manual adjustment or different approach. +## Masks -### Pre-built Templates +- `add_mask` - optional `vertices` / tangents in the layer's own pixel space, where `(0, 0)` is the layer's **top-left** corner. Also `mode` (`none`, `add`, `subtract`, `intersect`, `lighten`, `darken`, `difference`), `inverted`, `feather` (pixels), `opacity`, `expansion`. +- `set_mask_keyframes` animates the path; `set_mask_properties` changes settings and works through a lock. +- Identify masks by `maskName` or 1-based `maskIndex`. With neither, the first mask is used. -**Lower thirds** (create_lower_third): -- Styles: modern, corporate, news, minimal, social -- Position: bottomLeft, bottomRight, bottomCenter -- Automatically creates text and animated background +## 3D -**Title cards** (create_title_card): -- Styles: cinematic, documentary, social, minimal -- Full-screen animated title sequences +1. **Check the renderer first** with `get_3d_info`. New comps use the Classic renderer. Its internal name is `ADBE Advanced 3d` - that is *not* Advanced 3D. +2. `set_comp_renderer` takes `classic`, `advanced` or `cinema4d`. Each exposes a **different** set of properties; newer is not simply better: -**Transitions** (create_transition): -- Types: wipe_left, wipe_right, wipe_up, wipe_down, dissolve, push, slide, zoom -- Duration and easing customizable +| Feature | classic | advanced | cinema4d | +|---|---|---|---| +| Extrusion and bevel | no | yes | yes | +| Camera blur level, iris, highlights | yes | no | no | +| Depth of field, focus distance, aperture | yes | yes | no | +| Shadow colour, focus area width | no | yes | no | +| Reflections | no | no | yes | -**Logo reveals** (create_logo_reveal): -- Requires imported logo footage -- Styles: fade, scale, slide, spin, glitch, particle +3. `set_3d_layer` with `enable3D: true` before setting 3D properties. +4. Extrusion (`set_geometry_options` `extrusionDepth`, `bevelDepth`, `bevelStyle`) works on **text and shape layers only**, never solids. +5. The `set_*_options` tools apply what they can and return `applied` and `failed`. Each failure lists `availableUnderRenderers`. Read it and switch renderer, or tell the user the property isn't available - don't assume the call fully succeeded. -### Expression Best Practices +## Templates -1. **Use templates when possible**: apply_expression_template provides tested, working expressions -2. **Property references**: Use `thisLayer.transform.position` not just `position` -3. **Time-based**: Use `time` variable for continuous animation -4. **Value references**: Use `value` to reference current property value +- `create_lower_third` - `style` (`modern`, `corporate`, `news`, `minimal`, `social`), `position` (`bottomLeft`, `bottomRight`, `bottomCenter`). **`name` is the name of the precomp it builds**; `title` is the main line and `subtitle` the second. It adds one precomp layer to the target comp. +- `create_title_card` - `style`: `cinematic`, `documentary`, `social`, `minimal`. +- `create_transition` - `type`: `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `dissolve`, `push`, `slide`, `zoom`; `easing`: `linear`, `easeIn`, `easeOut`, `easeInOut`. +- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`. Avoid `particle`: it is listed but not implemented, so the logo gets no animation. +- `create_text_animator` - needs a text layer (`layerName`); `animatorType`: `typewriter`, `fadeInChars`, `scaleInChars`, `slideInChars`, `randomize`, `wave`; optional `duration`, `delay`. -### Common Errors and Solutions +## Effects and expressions -**"Property not found"** -- Verify exact property name spelling -- Check if property path includes parent groups -- Use get_layer_info to see available properties +- `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`). +- `apply_effect_template` - `template`: `gaussianBlur`, `directionalBlur`, `glassBlur`, `curves`, `colorBalance`, `brightnessContrast`, `vibrance`, `glow`, `dropShadow`, `vignette`, `cinematicLook`, `vhsRetro`, `neonGlow`, `filmGrain`, `chromaticAberration`, `duotone`; optional `intensity`. +- `apply_expression_template` - `template`: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut`, `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`, `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`, `bounce`, `inertia`, `overshoot`, `springy`. Pass settings in `params`: `wiggle` takes `frequency` and `amplitude`; `bounce` takes `amplitude`, `frequency` and `decay`; `springy` takes `mass`, `stiffness` and `damping`. Unknown keys are ignored. +- `link_properties` - `offset` is **added** to the linked value. It shifts a layer; it cannot scale motion. For parallax or other multipliers, write the expression with `set_expression`. +- In your own expressions, use `value` for the pre-expression value and `thisComp.layer("Name")` to reference other layers. -**"Value is not an array"** -- Check value format matches property type -- Scale requires array: `[100, 100]` -- Position can use object or array +## Rendering -**"Property is hidden"** -- Shape layer properties may be hidden initially -- Try different approach or manual layer setup -- Some properties only accessible after layer creation - -**"File couldn't be opened for writing"** -- Provide full absolute path for save operations -- Mac: `/Users/username/path/to/file.aep` -- Windows: `C:/Users/username/path/to/file.aep` - -## Project Organization Best Practices - -1. **Layer naming**: Use descriptive names for easy reference -2. **Precomposing**: Group related layers into precomps for organization -3. **Markers**: Add markers for timing references and notes -4. **Work area**: Set work area to focus rendering on specific sections -5. **Project structure**: Organize footage into folders using organize_project_items - -## Animation Timing Guidelines - -**Duration by content type:** -- UI animations: 0.3-0.5 seconds -- Text reveals: 0.5-1.5 seconds -- Logo animations: 1-3 seconds -- Full title cards: 3-5 seconds -- Transitions: 0.5-1.5 seconds - -**Easing recommendations:** -- Natural motion: Easy Ease (both) -- Bouncy entrance: Ease Out with overshoot -- Snappy exit: Ease In -- Continuous loop: Linear - -## Common Use Cases - -### Social Media Graphics -1. Create 1080x1920 (portrait) or 1920x1080 (landscape) comp -2. Add branded colors and text -3. Use quick animations (0.3-0.8s) -4. Apply modern, bold styles - -### Corporate Videos -1. Create 1920x1080 comp at 30fps -2. Use corporate/professional templates -3. Longer, smoother animations (1-2s) -4. Professional color grading effects - -### Motion Graphics Loops -1. Set looping expressions on properties -2. Use modulo operations for seamless loops -3. Match first and last frames -4. Time remap for speed variations - -### Explainer Animations -1. Build scene sequentially -2. Use text animators for callouts -3. Coordinate timing with markers -4. Parent layers for grouped motion - -## Performance Considerations - -- Complex expressions can slow preview -- Disable expressions during editing if needed -- Use proxies for heavy footage -- Reduce composition complexity when possible -- Pre-render complex sections - -## Integration Tips - -When working with imported footage: -1. Use import_footage for single files -2. Use import_folder for batches -3. Replace footage items with replace_footage -4. Find missing footage with find_missing_footage -5. Collect files before sharing: collect_files - -## Quick Reference: Most Used Functions - -**Essential:** -- create_project, save_project -- create_composition -- add_text_layer, add_shape_layer, add_solid_layer -- set_keyframe, apply_easy_ease -- save_project - -**Animation:** -- set_keyframe_advanced -- apply_expression_template -- create_text_animator - -**Effects:** -- apply_effect_template -- modify_effect_properties - -**Templates:** -- create_lower_third -- create_title_card -- create_transition - -**Organization:** -- list_compositions, list_layers -- get_composition_info, get_layer_info -- precompose_layers \ No newline at end of file +- Call `list_render_templates` first and use the user's existing presets by name. +- `add_to_render_queue` - `outputPath`, `renderSettingsTemplate`, `outputModuleTemplate`, `timeSpanStart`, `timeSpanDuration`. **The output module decides the file type**: a `.mov` path with an H.264 module produces `.mp4`. Report the returned `outputPath`. +- **`control_render` with `action: "start"` blocks After Effects until the whole queue finishes**, so the call will usually time out on a real render. Prefer `queue_in_ame` (`renderImmediately: true` to start encoding), which returns straight away. Poll `list_render_queue` for status. +- `render_frame` saves a single still, which is useful for checking your work. + +## Known issues + +- **`precompose_layers` is broken.** It moves the layers into the new precomp and then errors. If you must use it, check the result with `list_layers` afterwards. +- `delete_composition` does not delete the solids that comp used. They stay in the project's Solids folder. +- `create_logo_reveal` style `particle` does nothing (see Templates). + +## Common errors + +| Error | Likely cause | +|---|---| +| Timeout on every call | AE-MCP panel closed, or a modal dialog open in After Effects | +| "Property not found" | Dotted path, wrong group name (`Group 1`, not `Shape 1`), or the property doesn't exist on this layer type | +| "property or a parent property is hidden" | Renderer doesn't support it (see 3D), layer isn't 3D, or you asked for extrusion on a solid | +| "Value is undefined" | Enum written in the wrong case, such as `"Screen"` instead of `SCREEN` | +| "Unknown expression template" | Name not in the template list, such as `randomize`, which is a text animator type | +| "Nothing was changed" | A keyframe tool refused input that would have lost keys; nothing was modified | +| "Composition not found" / "Layer not found" | Check exact names with `list_compositions` / `list_layers` | +| File couldn't be saved | Use a full absolute path with forward slashes, such as `C:/Projects/job.aep` | + +## Timing guidelines + +- UI elements: 0.3-0.5 s. Text reveals: 0.5-1.5 s. Logo animations: 1-3 s. Title cards: 3-5 s. Transitions: 0.5-1.5 s. +- Natural motion: easy ease both ways. Entrances: ease out. Exits: ease in. Loops: linear. +- Common comp sizes: 1920x1080 landscape, 1080x1920 vertical, at 24, 30 or 60 fps. diff --git a/SKILL/examples.md b/SKILL/examples.md index 3e459f2..bad1957 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -1,232 +1,251 @@ -# Animation Examples +# ae-mcp Examples -This reference provides detailed examples of common After Effects animation patterns using the MCP tools. +Worked recipes using the ae-mcp tools. Every call uses named parameters that match the tool schemas. Adjust names, sizes and timings to the job. -## Example 1: Bouncing Ball Animation +Ask before running anything that changes the whole project (see SKILL.md), and check existing names with `list_compositions` / `list_layers` before editing. -Complete workflow for creating a bouncing ball with realistic physics: +--- + +## 1. Bouncing ball ``` -1. create_composition("Ball Animation", 1920, 1080, 5, 30) +create_composition(name="Ball", width=1920, height=1080, frameRate=30, duration=5) + +add_shape_layer(compName="Ball", name="Ball", shape="ellipse", + size={"width": 100, "height": 100}, + fillColor={"r": 1, "g": 0.3, "b": 0.2}, + position={"x": 960, "y": 200}) -2. add_shape_layer( - compName="Ball Animation", - name="Ball", - shape="ellipse", - size={"width": 100, "height": 100}, - fillColor={"r": 1.0, "g": 0.3, "b": 0.2}, - position={"x": 960, "y": 200} -) +# Drop and bounce - one call per key +set_keyframe(compName="Ball", layerName="Ball", property="position", time=0, value=[960, 200]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1, value=[960, 900]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1.5, value=[960, 700]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=2, value=[960, 900]) -3. Set position keyframes: - - time 0: position [960, 200] - - time 1: position [960, 900] - - time 1.5: position [960, 700] - - time 2: position [960, 900] - - time 2.3: position [960, 800] - - time 2.5: position [960, 900] +apply_easy_ease(compName="Ball", layerName="Ball", property="position", type="BOTH") -4. apply_easy_ease on position keyframes with type="IN" on downward motion +# Squash on impact +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=0.9, value=[100, 100]) +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1, value=[120, 80]) +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1.1, value=[100, 100]) +``` -5. Add squash/stretch on Scale: - - At impact frames: [120, 80] - - At peak frames: [90, 110] +Alternative - let an expression do the bounce after the last key: -Alternative: Use bounce expression template: -apply_expression_template("bounce", "Position", params={amplitude: 200, frequency: 2, decay: 0.8}) +``` +apply_expression_template(compName="Ball", layerName="Ball", property="position", + template="bounce", + params={"amplitude": 0.1, "frequency": 3, "decay": 5}) ``` -## Example 2: Kinetic Typography +--- -Animated text with multiple layers and timing: +## 2. Kinetic typography ``` -1. create_composition("Kinetic Text", 1920, 1080, 8, 30) - -2. Add three text layers: - add_text_layer("MOTION", fontSize=120, position={x: 960, y: 400}) - add_text_layer("DESIGN", fontSize=120, position={x: 960, y: 600}) - add_text_layer("STUDIO", fontSize=120, position={x: 960, y: 800}) +create_composition(name="Kinetic", width=1920, height=1080, frameRate=30, duration=8) -3. Stagger animations: - - Word 1: Animate at 0-1s - - Word 2: Animate at 0.5-1.5s - - Word 3: Animate at 1-2s +add_text_layer(compName="Kinetic", name="Word1", text="MOTION", fontSize=120, + justification="CENTER", position={"x": 960, "y": 400}) +add_text_layer(compName="Kinetic", name="Word2", text="DESIGN", fontSize=120, + justification="CENTER", position={"x": 960, "y": 600}) +add_text_layer(compName="Kinetic", name="Word3", text="STUDIO", fontSize=120, + justification="CENTER", position={"x": 960, "y": 800}) -4. Use text animator for each: - create_text_animator("slideInChars", duration=1, delay=0.05) +# create_text_animator needs a text layer +create_text_animator(compName="Kinetic", layerName="Word1", animatorType="slideInChars", duration=1, delay=0.05) +create_text_animator(compName="Kinetic", layerName="Word2", animatorType="slideInChars", duration=1, delay=0.05) +create_text_animator(compName="Kinetic", layerName="Word3", animatorType="slideInChars", duration=1, delay=0.05) -5. Add background sweep: - add_solid_layer(width=1920, height=200, color={r: 0.2, g: 0.6, b: 1.0}) - Animate position from top to bottom behind text +# Stagger the words by shifting each layer in time +modify_layer(compName="Kinetic", layerName="Word2", startTime=0.5) +modify_layer(compName="Kinetic", layerName="Word3", startTime=1) ``` -## Example 3: Logo Animation with Mask Reveal +--- + +## 3. Logo reveal with an animated mask + +The logo must already be in the project - ask before importing. ``` -1. import_footage("logo.png") +import_footage(path="C:/Assets/logo.png") # confirm with the user first + +create_composition(name="Logo", width=1920, height=1080, frameRate=30, duration=4) +add_av_layer(compName="Logo", itemName="logo.png") -2. add_av_layer(itemName="logo.png", startTime=0) +# Mask vertices are in the layer's own pixels, (0,0) = top-left. +# For a W x H logo, start with a zero-width rectangle on the left edge... +add_mask(compName="Logo", layerName="logo.png", name="Reveal", + vertices=[[0, 0], [0, 0], [0, H], [0, H]], feather=40) -3. Create mask: - - Add rectangle mask covering logo - - Animate Mask Path to reveal from left to right - -4. Add glow effect: - apply_effect_template("glow", intensity=50) +# ...and widen it to cover the whole logo. +set_mask_keyframes(compName="Logo", layerName="logo.png", maskName="Reveal", + keyframes=[ + {"time": 0, "vertices": [[0, 0], [0, 0], [0, H], [0, H]]}, + {"time": 1.5, "vertices": [[0, 0], [W, 0], [W, H], [0, H]]} + ]) -5. Fade in logo: - set_keyframe(property="Opacity", time=0, value=0) - set_keyframe(property="Opacity", time=1, value=100) - apply_easy_ease(property="Opacity", type="OUT") +apply_effect_template(compName="Logo", layerName="logo.png", template="glow", intensity=50) -6. Add subtle rotation: - set_keyframe(property="Rotation", time=0, value=-5) - set_keyframe(property="Rotation", time=2, value=0) +set_keyframe(compName="Logo", layerName="logo.png", property="rotation", time=0, value=-5) +set_keyframe(compName="Logo", layerName="logo.png", property="rotation", time=2, value=0) +apply_easy_ease(compName="Logo", layerName="logo.png", property="rotation", type="OUT") ``` -## Example 4: Social Media Call-to-Action +For a canned animation instead, `create_logo_reveal(compName="Logo", logoItemName="logo.png", style="scale")`. + +--- + +## 4. Line draw-on with Trim Paths ``` -1. create_composition("CTA", 1080, 1920, 6, 30) # Vertical for Stories +create_composition(name="DrawOn", width=1920, height=1080, frameRate=30, duration=3) + +# An open two-point path, centred on the comp +create_path(compName="DrawOn", name="Line", pathName="Stroke Path", + vertices=[[-400, 0], [400, 0]], closed=false, + strokeColor={"r": 1, "g": 1, "b": 1}, strokeWidth=6) -2. Background gradient: - add_solid_layer(color={r: 0.1, g: 0.1, b: 0.3}) - apply_effect("Gradient Ramp") +add_shape_operator(compName="DrawOn", layerName="Line", + operator="trimPaths", properties={"end": 0}) -3. Main text: - add_text_layer("FOLLOW US", fontSize=80, position={x: 540, y: 800}) - create_text_animator("scaleInChars", duration=0.8) +# The operator lives inside the group's Contents - note the slashes +set_keyframe(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", time=0, value=0) +set_keyframe(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", time=1, value=100) +apply_easy_ease(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", type="BOTH") +``` -4. Animated button: - add_shape_layer(shape="rectangle", size={width: 400, height: 100}) - Set position keyframes with bounce - -5. Pulsing effect: - apply_expression_template("springy", "Scale", params={frequency: 2}) +For a curve, add tangents (relative to each vertex). A circle of radius 100: -6. Add arrow icon: - add_shape_layer(shape="polygon", points=3) - Rotate and position as arrow - Animate position with wiggle ``` +vertices = [[0, -100], [100, 0], [0, 100], [-100, 0]] +inTangents = [[-55, 0], [0, -55], [55, 0], [0, 55]] +outTangents = [[55, 0], [0, 55], [-55, 0], [0, -55]] +``` + +--- -## Example 5: Data Visualization Counter +## 5. Extruded 3D title ``` -1. create_composition("Counter", 1920, 1080, 5, 30) +create_composition(name="Title3D", width=1920, height=1080, frameRate=30, duration=5) +add_text_layer(compName="Title3D", name="Title", text="LAUNCH", fontSize=200, + justification="CENTER", position={"x": 960, "y": 540}) -2. add_text_layer("0", fontSize=200, position={x: 960, y: 540}) +get_3d_info(compName="Title3D") # new comps start on the Classic renderer +set_comp_renderer(compName="Title3D", renderer="advanced") -3. Apply expression to Source Text: - expression = ` - startValue = 0; - endValue = 1000; - duration = 3; - - if (time < duration) { - currentValue = Math.round(linear(time, 0, duration, startValue, endValue)); - } else { - currentValue = endValue; - } - - currentValue.toString(); - ` - set_expression(property="Source Text", expression=expression) +set_3d_layer(compName="Title3D", layerName="Title", enable3D=true, orientation=[0, 20, 0]) +set_geometry_options(compName="Title3D", layerName="Title", + extrusionDepth=60, bevelStyle=2, bevelDepth=4) +set_material_options(compName="Title3D", layerName="Title", + castsShadows=true, ambient=40, diffuse=70, specularIntensity=55) -4. Add prefix/suffix for units ($, %, etc.) +add_camera_layer(compName="Title3D", name="Cam", type="TWO_NODE") +set_camera_options(compName="Title3D", layerName="Cam", + zoom=1200, depthOfField=true, focusDistance=1200, aperture=40) -5. Add subtle scale pulse: - apply_expression_template("springy", "Scale") +add_light_layer(compName="Title3D", name="Key", type="SPOT") +set_light_options(compName="Title3D", layerName="Key", + intensity=120, color={"r": 1, "g": 0.9, "b": 0.7}, + coneAngle=70, coneFeather=40, castsShadows=true, shadowDarkness=60) ``` -## Example 6: Parallax Scrolling Scene +Read the `failed` list in each `set_*_options` result. Under the `advanced` renderer, for example, camera `blurLevel` and the iris controls are refused, and each failure names the renderer that supports it. + +--- + +## 6. Number counter + +``` +create_composition(name="Counter", width=1920, height=1080, frameRate=30, duration=5) +add_text_layer(compName="Counter", name="Count", text="0", fontSize=200, + justification="CENTER", position={"x": 960, "y": 540}) +set_expression(compName="Counter", layerName="Count", property="sourceText", + expression="Math.round(linear(time, 0, 3, 0, 1000)).toString()") ``` -1. create_composition("Parallax", 1920, 1080, 10, 30) -2. Import multiple layers (background, midground, foreground) +For a prefix or suffix: `"$" + Math.round(linear(time, 0, 3, 0, 1000)).toString()`. -3. Set up null object as camera controller: - add_null_layer(name="Camera Control") +--- -4. Link all layers to null: - For each layer, link_properties( - sourceProperty="Position", - targetProperty="Position", - offset=[layer_specific_multiplier, 0] - ) +## 7. Parallax with a controller null -5. Animate null object position: - set_keyframe on Camera Control position - Different layers move at different speeds based on offset +`link_properties` can only *add* an offset, so parallax uses an expression with a multiplier. -6. Add motion blur for smoothness: - modify_layer(layerName=each_layer, motionBlur=true) ``` +add_null_layer(compName="Scene", name="Camera Control") -## Example 7: Glitch Effect Title +# Each layer moves by a fraction of the controller's motion. +# 0.2 = far background, 0.5 = midground, 1.0 = foreground. +set_expression(compName="Scene", layerName="Background", property="position", + expression='var c = thisComp.layer("Camera Control").transform.position; value + (c - c.valueAtTime(0)) * 0.2') +set_expression(compName="Scene", layerName="Midground", property="position", + expression='var c = thisComp.layer("Camera Control").transform.position; value + (c - c.valueAtTime(0)) * 0.5') +# Animate only the controller +set_keyframe(compName="Scene", layerName="Camera Control", property="position", time=0, value=[960, 540]) +set_keyframe(compName="Scene", layerName="Camera Control", property="position", time=10, value=[560, 540]) ``` -1. create_composition("Glitch Title", 1920, 1080, 4, 30) -2. add_text_layer("GLITCH", fontSize=150) +--- -3. Duplicate layer 3 times +## 8. Glitch title -4. Apply chromatic aberration: - - Layer 1 (Red): Shift position left, blend mode "Screen" - - Layer 2 (Green): Keep center - - Layer 3 (Blue): Shift position right, blend mode "Screen" +There is no duplicate-layer tool, so build the three copies directly. -5. Add displacement: - apply_effect("Turbulent Displace") - Animate Evolution for movement - -6. Flicker opacity: - apply_expression_template("randomize", "Opacity", params={min: 50, max: 100}) ``` +create_composition(name="Glitch", width=1920, height=1080, frameRate=30, duration=4) + +add_text_layer(compName="Glitch", name="Red", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 1, "g": 0, "b": 0}, position={"x": 950, "y": 540}) +add_text_layer(compName="Glitch", name="Green", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 0, "g": 1, "b": 0}, position={"x": 960, "y": 540}) +add_text_layer(compName="Glitch", name="Blue", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 0, "g": 0, "b": 1}, position={"x": 970, "y": 540}) + +# Blend modes are AE constants in capitals +modify_layer(compName="Glitch", layerName="Red", blendMode="SCREEN") +modify_layer(compName="Glitch", layerName="Blue", blendMode="SCREEN") -## Example 8: Progress Bar Animation +apply_effect(compName="Glitch", layerName="Green", effect="Turbulent Displace") +# Flicker +apply_expression_template(compName="Glitch", layerName="Red", property="opacity", + template="wiggle", params={"frequency": 12, "amplitude": 40}) ``` -1. create_composition("Progress", 1920, 1080, 3, 30) -2. Create background bar: - add_shape_layer(shape="rectangle", size={width: 1200, height: 80}) - fillColor = gray +--- -3. Create progress bar: - add_shape_layer(shape="rectangle", size={width: 1200, height: 80}) - fillColor = blue - -4. Mask progress bar: - Add mask to progress bar layer - Animate Mask Path from 0% to 100% width +## 9. Render and deliver -5. Add percentage text: - add_text_layer("0%") - Apply counter expression synced with mask animation ``` +list_render_templates() # use the user's existing presets by name -## Timing and Easing Patterns +add_to_render_queue(compName="Title3D", + outputPath="D:/Deliveries/Title3D.mp4", + renderSettingsTemplate="Best Settings", + outputModuleTemplate="H.264 - Match Render Settings - 15 Mbps") + +list_render_queue() # confirm status "queued" and the real outputPath + +queue_in_ame(renderImmediately=true) # returns immediately; AME does the encoding +``` -**Sharp transitions** (UI elements): -- Duration: 0.3s -- Easing: Easy Ease In/Out -- Use for: Buttons, menus, toggles +Avoid `control_render(action="start")` for anything long - it blocks After Effects until the queue finishes, and the call will time out. -**Smooth reveals** (content): -- Duration: 0.8-1.2s -- Easing: Easy Ease Out -- Use for: Text, images, cards +--- -**Energetic motion** (attention-grabbing): -- Duration: 0.4-0.6s -- Easing: Overshoot/bounce -- Use for: Logos, highlights, CTAs +## Timing and easing -**Continuous animation** (ambient): -- Duration: Loop (2-4s) -- Easing: Linear or sine wave -- Use for: Backgrounds, subtle movement \ No newline at end of file +| Use | Duration | Easing | +|---|---|---| +| UI elements, buttons | 0.3 s | Easy ease both ways | +| Text and image reveals | 0.8-1.2 s | Ease out | +| Logos, call-to-action | 0.4-0.6 s | `overshoot` or `bounce` expression | +| Ambient loops | 2-4 s | Linear, or `loopCycle` | From 3f34d8534f9e6baccd284e45c599735d6d875040 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:13 -0400 Subject: [PATCH 13/25] feat(templates): implement the particle logo reveal create_logo_reveal accepted style "particle" but had no branch for it, so the logo got no animation and the call still reported success. ExtendScript has no particle API, but the bundled particle effects are ordinary effects that can be added by match name. The reveal now: 1. applies CC Scatterize to the logo and animates Scatter and Right Twist down to 0, so the logo assembles out of particles, fading in across most of the move; 2. fires a short CC Particle Systems II burst on its own solid ("Logo Particles", ADD blend) as the logo lands. The values were chosen by rendering frames and looking at them, because several parameters don't behave as their names suggest: - Particle Type is a numbered popup whose indices include menu separators. The default, 1, draws lines. 5 is Faded Sphere, picked from a rendered contact sheet of all 20 entries. - Radius X/Y and Velocity are relative units, not pixels. Passing the logo's pixel size filled the whole frame with particles. On a 1920-wide layer one radius unit is about layer width / 200, so the spawn ellipse is sized from the logo with that ratio. - Scatterize can't move pixels outside the logo layer's bounds, so a large Scatter drew a solid rectangle of noise. It now starts at 150. Effect parameters are addressed by match name, since effect property lists are flat and reuse display names. If CC Particle Systems II is unavailable, the reveal still runs and reports particleBurstAdded: false. Co-Authored-By: Claude Opus 5 --- .../generators/templateGenerators.ts | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ae-integration/generators/templateGenerators.ts b/src/ae-integration/generators/templateGenerators.ts index dc6e7e8..7bd6bab 100644 --- a/src/ae-integration/generators/templateGenerators.ts +++ b/src/ae-integration/generators/templateGenerators.ts @@ -482,13 +482,96 @@ export function generateCreateLogoReveal(params: { script += 'logoLayer.property("Opacity").setValueAtTime(0.2, 100);\n'; script += 'logoLayer.property("Opacity").setValueAtTime(0.25, 0);\n'; script += 'logoLayer.property("Opacity").setValueAtTime(0.3, 100);\n'; + } else if (style === 'particle') { + // "particle" used to be accepted with no implementation, so the logo got no + // animation. ExtendScript has no particle API, but the bundled particle + // effects are ordinary effects: add them by match name and set their + // parameters. Two parts: + // 1. CC Scatterize on the logo, Scatter animating to 0, so the logo + // assembles out of particles. + // 2. A CC Particle Systems II burst on its own solid at the moment the + // logo lands. + // Effect parameters are addressed by match name ("CC Scatterize-0001"): + // effect property lists are flat and reuse display names. + const land = Math.round(duration * 0.5 * 100) / 100; + + script += 'var logoFx = logoLayer.property("ADBE Effect Parade");\n'; + script += 'if (!logoFx.canAddProperty("CC Scatterize")) {\n'; + script += ' throw new Error("CC Scatterize is not available in this After Effects install");\n'; + script += '}\n'; + script += 'var scat = logoFx.addProperty("CC Scatterize");\n'; + script += 'var scatAmount = scat.property("CC Scatterize-0001");\n'; + script += 'var scatTwist = scat.property("CC Scatterize-0002");\n'; + // Scatterize can't move pixels outside the logo layer's bounds, so a large + // value just fills that rectangle with noise. Keep it modest. + script += 'scatAmount.setValueAtTime(0, 150);\n'; + script += 'scatAmount.setValueAtTime(' + land + ', 0);\n'; + script += 'scatTwist.setValueAtTime(0, 180);\n'; + script += 'scatTwist.setValueAtTime(' + land + ', 0);\n'; + // Decelerate into the assembled logo. One-element ease: these are 1D. + script += 'var landEase = new KeyframeEase(0, 80);\n'; + script += 'scatAmount.setTemporalEaseAtKey(2, [landEase], [landEase]);\n'; + script += 'scatTwist.setTemporalEaseAtKey(2, [landEase], [landEase]);\n'; + // Fade in across most of the assembly, so the scattered stage stays faint + // and the layer's bounding box never reads as a solid rectangle. + script += 'logoLayer.property("Opacity").setValueAtTime(0, 0);\n'; + script += 'logoLayer.property("Opacity").setValueAtTime(' + Math.round(land * 0.85 * 100) / 100 + ', 100);\n'; + + script += 'var particleFxOk = true;\n'; + script += 'var burst = null;\n'; + script += 'var probeFx = logoFx.canAddProperty("CC Particle Systems II");\n'; + script += 'if (probeFx) {\n'; + script += ' burst = comp.layers.addSolid([1, 1, 1], "Logo Particles", comp.width, comp.height, 1);\n'; + script += ' burst.outPoint = ' + duration + ';\n'; + script += ' burst.blendingMode = BlendingMode.ADD;\n'; + script += ' var ps = burst.property("ADBE Effect Parade").addProperty("CC Particle Systems II");\n'; + script += ' var logoPos = logoLayer.property("ADBE Transform Group").property("ADBE Position").value;\n'; + script += ' ps.property("CC Particle Systems II-0004").setValue([logoPos[0], logoPos[1]]);\n'; + // Spawn across the logo's footprint. Radius X/Y are NOT pixels: measured + // on a 1920-wide layer, one unit is roughly layer width / 200 (~9.6 px), + // so passing the logo's pixel size filled the whole frame. + script += ' var pxPerRadiusUnit = burst.width / 200;\n'; + script += ' ps.property("CC Particle Systems II-0005").setValue(Math.min(1000, (logoLayer.width / 2) / pxPerRadiusUnit * 0.9));\n'; + script += ' ps.property("CC Particle Systems II-0006").setValue(Math.min(1000, (logoLayer.height / 2) / pxPerRadiusUnit * 0.9));\n'; + script += ' ps.property("CC Particle Systems II-0002").setValue(1);\n'; // longevity + // Velocity scales with the layer's size, so keep it low on a comp-sized solid. + script += ' ps.property("CC Particle Systems II-0010").setValue(0.1);\n'; // velocity + script += ' ps.property("CC Particle Systems II-0012").setValue(0.05);\n'; // gravity + script += ' ps.property("CC Particle Systems II-0013").setValue(10);\n'; // resistance + script += ' ps.property("CC Particle Systems II-0019").setValue(0.12);\n'; // birth size + script += ' ps.property("CC Particle Systems II-0020").setValue(0);\n'; // death size + script += ' ps.property("CC Particle Systems II-0023").setValue(1);\n'; // max opacity + // Particle Type popup: 5 = Faded Sphere. The default, 1, draws lines. The + // indices include menu separators (3, 8 and 16 render almost nothing), so + // this was picked from a rendered contact sheet, not the menu order. + script += ' ps.property("CC Particle Systems II-0018").setValue(5);\n'; + script += ' ps.property("CC Particle Systems II-0025").setValue([1, 1, 1, 1]);\n'; // birth colour + script += ' ps.property("CC Particle Systems II-0026").setValue([1, 1, 1, 1]);\n'; // death colour + // Birth rate: off, a short burst as the logo lands, off again. HOLD keys + // so it switches rather than ramping. + script += ' var rate = ps.property("CC Particle Systems II-0001");\n'; + script += ' rate.setValueAtTime(0, 0);\n'; + script += ' rate.setValueAtTime(' + land + ', 6);\n'; + script += ' rate.setValueAtTime(' + Math.round((land + 0.2) * 100) / 100 + ', 0);\n'; + script += ' for (var rk = 1; rk <= rate.numKeys; rk++) {\n'; + script += ' rate.setInterpolationTypeAtKey(rk, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);\n'; + script += ' }\n'; + script += '} else {\n'; + // The reveal still works without the burst. + script += ' particleFxOk = false;\n'; + script += '}\n'; } - script += generateResultObject({ + const resultProps: Record = { logoLayerIndex: 'logoLayer.index', style: '"' + escapeString(style) + '"', duration: String(duration) - }); + }; + if (style === 'particle') { + resultProps.particleLayer = 'burst ? burst.name : null'; + resultProps.particleBurstAdded = 'particleFxOk'; + } + script += generateResultObject(resultProps); return wrapInUndoGroup(script, 'Create Logo Reveal'); } From 69b3214600abf8362a584ff7315662f982f6f7ca Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:13 -0400 Subject: [PATCH 14/25] fix(render_frame): wait for saveFrameToPng, and stop trusting stale files render_frame reported every render as failed. saveFrameToPng returns before the PNG is on disk - measured on AE 26.5, it appears about 50 ms later - and the tool checked for the file immediately, then threw "Frame render did not produce a file" while the file was still arriving. It now waits up to 15 s for the file, then until its size has held steady for three checks, so callers never read a half-written PNG. The result includes bytes and waitedMs. It also deletes any existing file at the output path first. Previously a leftover PNG from an earlier call satisfied the existence check even when nothing new was written. Co-Authored-By: Claude Opus 5 --- .../generators/compositionGenerators.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/ae-integration/generators/compositionGenerators.ts b/src/ae-integration/generators/compositionGenerators.ts index abb726a..0e90706 100644 --- a/src/ae-integration/generators/compositionGenerators.ts +++ b/src/ae-integration/generators/compositionGenerators.ts @@ -293,15 +293,32 @@ export function generateRenderFrame(params: { script += 'if (!comp.saveFrameToPng) {\n'; script += ' throw new Error("saveFrameToPng is not available in this After Effects version");\n'; script += '}\n'; + // Remove any previous render first. Otherwise an old file satisfies the + // existence check below even when nothing new was written. + script += 'if (outFile.exists) { outFile.remove(); }\n'; script += 'comp.saveFrameToPng(t, outFile);\n'; + // saveFrameToPng returns BEFORE the file is on disk (measured on AE 26.5: it + // appears ~50 ms later). Checking immediately reported every render as a + // failure. Wait for the file to appear, then for its size to stop changing, + // so the caller never reads a half-written PNG. + script += 'var waitedMs = 0;\n'; + script += 'while (!outFile.exists && waitedMs < 15000) { $.sleep(50); waitedMs += 50; }\n'; script += 'if (!outFile.exists) {\n'; - script += ' throw new Error("Frame render did not produce a file: " + outFile.fsName);\n'; + script += ' throw new Error("Frame render did not produce a file within 15s: " + outFile.fsName);\n'; + script += '}\n'; + script += 'var lastSize = -1, stableChecks = 0;\n'; + script += 'while (stableChecks < 3 && waitedMs < 15000) {\n'; + script += ' $.sleep(50); waitedMs += 50;\n'; + script += ' if (outFile.length > 0 && outFile.length === lastSize) { stableChecks++; } else { stableChecks = 0; }\n'; + script += ' lastSize = outFile.length;\n'; script += '}\n'; script += generateResultObject({ success: 'true', time: 't', - path: 'outFile.fsName' + path: 'outFile.fsName', + bytes: 'outFile.length', + waitedMs: 'waitedMs' }); return script; From 848f5964d6c4d4c8e8e66f81b448ca07d37fd190 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:13 -0400 Subject: [PATCH 15/25] fix(aliases): point three property shortcuts at properties that exist Verified against AE 26.5 lights and cameras: - pointOfInterest mapped to "ADBE Camera Options Group/ADBE Camera Point of Interest", which doesn't exist. A camera's Point of Interest is its anchor point, relabelled: ADBE Transform Group/ADBE Anchor Point. A set_keyframe through the shortcut now keys it and reads back. - coneFeather mapped to "ADBE Light Cone Feather"; current lights use "ADBE Light Cone Feather 2". - castsShadows mapped to "ADBE Light Casts Shadows"; lights use the shared "ADBE Casts Shadows". Co-Authored-By: Claude Opus 5 --- src/types/aeTypes.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/types/aeTypes.ts b/src/types/aeTypes.ts index 55adf4e..ac35448 100644 --- a/src/types/aeTypes.ts +++ b/src/types/aeTypes.ts @@ -282,7 +282,9 @@ export const AE_PROPERTY_PATHS: Record = { 'audioLevels': 'ADBE Audio Group/ADBE Audio Levels', // Camera - 'pointOfInterest': 'ADBE Camera Options Group/ADBE Camera Point of Interest', + // A camera's Point of Interest is its anchor point - AE only relabels it. + // There is no "ADBE Camera Point of Interest" property. + 'pointOfInterest': 'ADBE Transform Group/ADBE Anchor Point', 'zoom': 'ADBE Camera Options Group/ADBE Camera Zoom', 'depthOfField': 'ADBE Camera Options Group/ADBE Camera Depth of Field', 'focusDistance': 'ADBE Camera Options Group/ADBE Camera Focus Distance', @@ -293,8 +295,10 @@ export const AE_PROPERTY_PATHS: Record = { 'lightIntensity': 'ADBE Light Options Group/ADBE Light Intensity', 'lightColor': 'ADBE Light Options Group/ADBE Light Color', 'coneAngle': 'ADBE Light Options Group/ADBE Light Cone Angle', - 'coneFeather': 'ADBE Light Options Group/ADBE Light Cone Feather', - 'castsShadows': 'ADBE Light Options Group/ADBE Light Casts Shadows', + // "ADBE Light Cone Feather" (no suffix) is not on current lights. + 'coneFeather': 'ADBE Light Options Group/ADBE Light Cone Feather 2', + // Lights reuse the material match name; "ADBE Light Casts Shadows" doesn't exist. + 'castsShadows': 'ADBE Light Options Group/ADBE Casts Shadows', 'shadowDarkness': 'ADBE Light Options Group/ADBE Light Shadow Darkness', 'shadowDiffusion': 'ADBE Light Options Group/ADBE Light Shadow Diffusion', From acb44e1ad351957e38aa623f48997f741483ce66 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:13 -0400 Subject: [PATCH 16/25] docs(skill): document particles and the repaired shortcuts - Adds a Particles section: which bundled effects provide particles, that emitters replace their layer, CC Particle Systems II's relative Radius/Velocity units, the Particle Type menu indices (with the separators), and HOLD-keyed bursts. - Notes that effect parameter lists reuse display names, and that a parameter's match name works as the last path segment. - create_logo_reveal "particle" is now implemented, so the warning and known-issue entry are gone. - Lists the camera and light shortcuts now that pointOfInterest, coneFeather and castsShadows resolve. - render_frame now waits for the file. Co-Authored-By: Claude Opus 5 --- SKILL/SKILL.md | 22 ++++++++++++++++++---- SKILL/examples.md | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 2be4a28..397397f 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -1,6 +1,6 @@ --- name: ae-mcp -description: Drive Adobe After Effects through the ae-mcp tools. Use when the user wants to create, edit, animate or render anything in After Effects - compositions, text, shape layers and bezier paths, trim paths and other shape operators, masks, keyframes and easing, expressions, effects, 3D layers with cameras, lights and extrusion, lower thirds, title cards, transitions, logo reveals, markers, and the render queue or Adobe Media Encoder. Trigger on phrases like "in After Effects", "make a motion graphic", "animate this", "add keyframes", "draw on a line", "mask this", "extrude the text", "render this comp", or AE terms like comp, precomp, expression, trim paths, render queue. +description: Drive Adobe After Effects through the ae-mcp tools. Use when the user wants to create, edit, animate or render anything in After Effects - compositions, text, shape layers and bezier paths, trim paths and other shape operators, masks, keyframes and easing, expressions, effects and particles (CC Particle World, Particle Systems II, Particle Playground), 3D layers with cameras, lights and extrusion, lower thirds, title cards, transitions, logo reveals, markers, and the render queue or Adobe Media Encoder. Trigger on phrases like "in After Effects", "make a motion graphic", "animate this", "add keyframes", "draw on a line", "mask this", "extrude the text", "render this comp", or AE terms like comp, precomp, expression, trim paths, render queue. --- # After Effects MCP @@ -29,6 +29,9 @@ Keyframe, expression and effect-property tools take a `property` string. | `rotationX`, `rotationY`, `rotationZ` | 3D rotation | | `sourceText` | Text layer Source Text | | `audioLevels` | Audio levels | +| `pointOfInterest` | Camera point of interest (AE stores it as the camera's anchor point) | +| `zoom`, `focusDistance`, `aperture` | Camera options | +| `lightIntensity`, `lightColor`, `coneAngle`, `coneFeather`, `castsShadows`, `shadowDarkness`, `shadowDiffusion` | Light options | English display names (`"Position"`, `"Rotation"`, `"Source Text"`) also work on an English install. @@ -126,7 +129,7 @@ If one of these refuses with "Nothing was changed", the keyframes are untouched - `create_lower_third` - `style` (`modern`, `corporate`, `news`, `minimal`, `social`), `position` (`bottomLeft`, `bottomRight`, `bottomCenter`). **`name` is the name of the precomp it builds**; `title` is the main line and `subtitle` the second. It adds one precomp layer to the target comp. - `create_title_card` - `style`: `cinematic`, `documentary`, `social`, `minimal`. - `create_transition` - `type`: `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `dissolve`, `push`, `slide`, `zoom`; `easing`: `linear`, `easeIn`, `easeOut`, `easeInOut`. -- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`. Avoid `particle`: it is listed but not implemented, so the logo gets no animation. +- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`, `particle`. `particle` assembles the logo out of particles with CC Scatterize and fires a soft CC Particle Systems II burst as it lands. The burst goes on its own solid, "Logo Particles", and the result says whether it was added. - `create_text_animator` - needs a text layer (`layerName`); `animatorType`: `typewriter`, `fadeInChars`, `scaleInChars`, `slideInChars`, `randomize`, `wave`; optional `duration`, `delay`. ## Effects and expressions @@ -134,21 +137,32 @@ If one of these refuses with "Nothing was changed", the keyframes are untouched - `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`). - `apply_effect_template` - `template`: `gaussianBlur`, `directionalBlur`, `glassBlur`, `curves`, `colorBalance`, `brightnessContrast`, `vibrance`, `glow`, `dropShadow`, `vignette`, `cinematicLook`, `vhsRetro`, `neonGlow`, `filmGrain`, `chromaticAberration`, `duotone`; optional `intensity`. - `apply_expression_template` - `template`: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut`, `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`, `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`, `bounce`, `inertia`, `overshoot`, `springy`. Pass settings in `params`: `wiggle` takes `frequency` and `amplitude`; `bounce` takes `amplitude`, `frequency` and `decay`; `springy` takes `mass`, `stiffness` and `damping`. Unknown keys are ignored. +- **Effect parameter lists are flat and reuse names.** Particle Playground has a dozen `Position`, `Affects`, `Min` and `Max` entries; CC Particle World has two `Distance`s. A display-name path such as `Effects/Particle Playground/Position` silently hits the first one. Use the parameter's match name as the last path segment instead: `Effects/CC Particle Systems II/CC Particle Systems II-0001`. - `link_properties` - `offset` is **added** to the linked value. It shifts a layer; it cannot scale motion. For parallax or other multipliers, write the expression with `set_expression`. - In your own expressions, use `value` for the pre-expression value and `thisComp.layer("Name")` to reference other layers. +## Particles + +There is no particle API in ExtendScript. Particles come from the bundled effects, added with `apply_effect` like any other effect: `CC Particle World`, `CC Particle Systems II`, `Particle Playground`, and effects that break a layer apart - `CC Scatterize`, `CC Pixel Polly`, `Shatter`. Emitters (Particle World, Particle Systems II, Playground) replace the layer they sit on, so put them on their own solid, usually with `blendMode: ADD`. + +CC Particle Systems II behaves in ways that aren't obvious (match names in brackets): +- **Radius X/Y (`-0005`/`-0006`) and Velocity (`-0010`) are relative, not pixels.** On a 1920-wide layer one radius unit is roughly 10 px, so a radius of 30 surrounds a mid-size logo and 100 fills the frame. Keep velocity well under 1 on a comp-sized solid. +- **Particle Type (`-0018`) is a numbered menu**: 1 lines (the default), 2 stars, 4 shaded spheres, **5 faded spheres (soft dots)**, 7 bubbles, 10-15 polygon shards, 17-19 large bokeh lenses. 3, 8 and 16 are menu separators and render almost nothing. +- Birth Rate (`-0001`) runs 0-1000 and is continuous. For a burst, keyframe it on and off with HOLD keyframes. + +Check a particle setup with `render_frame` before calling it done - these parameters are hard to judge any other way. + ## Rendering - Call `list_render_templates` first and use the user's existing presets by name. - `add_to_render_queue` - `outputPath`, `renderSettingsTemplate`, `outputModuleTemplate`, `timeSpanStart`, `timeSpanDuration`. **The output module decides the file type**: a `.mov` path with an H.264 module produces `.mp4`. Report the returned `outputPath`. - **`control_render` with `action: "start"` blocks After Effects until the whole queue finishes**, so the call will usually time out on a real render. Prefer `queue_in_ame` (`renderImmediately: true` to start encoding), which returns straight away. Poll `list_render_queue` for status. -- `render_frame` saves a single still, which is useful for checking your work. +- `render_frame` saves a single PNG (`time`, optional `outputDir` and `fileName`) and waits until the file is fully written. Use it to check your work. ## Known issues - **`precompose_layers` is broken.** It moves the layers into the new precomp and then errors. If you must use it, check the result with `list_layers` afterwards. - `delete_composition` does not delete the solids that comp used. They stay in the project's Solids folder. -- `create_logo_reveal` style `particle` does nothing (see Templates). ## Common errors diff --git a/SKILL/examples.md b/SKILL/examples.md index bad1957..278b61e 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -93,7 +93,7 @@ set_keyframe(compName="Logo", layerName="logo.png", property="rotation", time=2, apply_easy_ease(compName="Logo", layerName="logo.png", property="rotation", type="OUT") ``` -For a canned animation instead, `create_logo_reveal(compName="Logo", logoItemName="logo.png", style="scale")`. +For a canned animation instead, `create_logo_reveal(compName="Logo", logoItemName="logo.png", style="scale")`. `style="particle"` assembles the logo out of particles and adds a sparkle burst as it lands. --- From 6bcb9e1d596c72ad68d560c8d6a8c5d9f25ecad1 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:03:01 -0400 Subject: [PATCH 17/25] feat(layers): collapseTransformation, and unclip the particle reveal Effects render inside their layer's bounds. With CC Scatterize on a tight text or logo precomp, the scatter filled that rectangle and stopped at its edges, which read as a hard box. The earlier fix only lowered Scatter to make the box less visible. create_logo_reveal "particle" now gives the logo room first: - if the layer can collapse (precomps, solids, vector layers), it turns on Collapse Transformations so effects render in the parent comp's space; - otherwise, for raster footage such as a PNG, it adds Grow Bounds ("ADBE GROW BOUNDS", by match name - a Red Giant plugin shares the display name) before Scatterize. With room available, Scatter starts at 450 instead of 150. The result reports logoBoundsExpandedBy. canSetCollapseTransformation is the test, not layer type: solids report true, a PNG layer reports false. modify_layer gains a collapseTransformation parameter, with a clear error on layers that can't collapse. Verified with renders of a text precomp (collapsed) and a PNG (grow bounds): in both the scatter now extends past the source bounds with soft edges. Co-Authored-By: Claude Opus 5 --- .../generators/layerGenerators.ts | 8 +++++++ .../generators/templateGenerators.ts | 23 ++++++++++++++++--- src/stdio-server.ts | 1 + 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/ae-integration/generators/layerGenerators.ts b/src/ae-integration/generators/layerGenerators.ts index 1d77cab..40c0178 100644 --- a/src/ae-integration/generators/layerGenerators.ts +++ b/src/ae-integration/generators/layerGenerators.ts @@ -530,6 +530,7 @@ export function generateModifyLayer(params: { blendMode?: string; parent?: number; is3D?: boolean; + collapseTransformation?: boolean; position?: { x: number; y: number; z?: number }; scale?: number[]; rotation?: number; @@ -580,6 +581,13 @@ export function generateModifyLayer(params: { if (params.is3D !== undefined) { script += 'layer.threeDLayer = ' + params.is3D + ';\n'; } + if (params.collapseTransformation !== undefined) { + // Precomps, solids and vector layers can collapse; raster footage can't. + script += 'if (!layer.canSetCollapseTransformation) {\n'; + script += ' throw new Error("Collapse Transformations is not available on this layer (raster footage can\'t collapse; use the Grow Bounds effect to give effects more room): " + layer.name);\n'; + script += '}\n'; + script += 'layer.collapseTransformation = ' + (params.collapseTransformation ? 'true' : 'false') + ';\n'; + } if (params.position) { script += 'layer.property("Position").setValue(' + positionToES3(params.position) + ');\n'; } diff --git a/src/ae-integration/generators/templateGenerators.ts b/src/ae-integration/generators/templateGenerators.ts index 7bd6bab..df72e35 100644 --- a/src/ae-integration/generators/templateGenerators.ts +++ b/src/ae-integration/generators/templateGenerators.ts @@ -499,12 +499,28 @@ export function generateCreateLogoReveal(params: { script += 'if (!logoFx.canAddProperty("CC Scatterize")) {\n'; script += ' throw new Error("CC Scatterize is not available in this After Effects install");\n'; script += '}\n'; + // Effects are clipped to the layer's own bounds, so Scatterize on a tight + // logo just fills that rectangle with noise. Give it room first: + // - precomps (and solids / vectors) can use Collapse Transformations, + // which renders their effects in this comp's space; + // - raster footage such as a PNG can't collapse, so Grow Bounds enlarges + // the layer's buffer instead. It must sit BEFORE Scatterize in the stack. + // canSetCollapseTransformation is the reliable test - layer type isn't. + script += 'var logoExpanded = "none";\n'; + script += 'if (logoLayer.canSetCollapseTransformation) {\n'; + script += ' logoLayer.collapseTransformation = true;\n'; + script += ' logoExpanded = "collapseTransformation";\n'; + script += '} else if (logoFx.canAddProperty("ADBE GROW BOUNDS")) {\n'; + script += ' var grow = logoFx.addProperty("ADBE GROW BOUNDS");\n'; + script += ' grow.property("ADBE GROW BOUNDS-0001").setValue(Math.round(Math.max(comp.width, comp.height) / 3));\n'; + script += ' logoExpanded = "growBounds";\n'; + script += '}\n'; script += 'var scat = logoFx.addProperty("CC Scatterize");\n'; script += 'var scatAmount = scat.property("CC Scatterize-0001");\n'; script += 'var scatTwist = scat.property("CC Scatterize-0002");\n'; - // Scatterize can't move pixels outside the logo layer's bounds, so a large - // value just fills that rectangle with noise. Keep it modest. - script += 'scatAmount.setValueAtTime(0, 150);\n'; + // With room to spread, a bigger scatter reads as a particle cloud. Without + // it, keep the value modest so the clipped rectangle stays faint. + script += 'scatAmount.setValueAtTime(0, logoExpanded === "none" ? 150 : 450);\n'; script += 'scatAmount.setValueAtTime(' + land + ', 0);\n'; script += 'scatTwist.setValueAtTime(0, 180);\n'; script += 'scatTwist.setValueAtTime(' + land + ', 0);\n'; @@ -570,6 +586,7 @@ export function generateCreateLogoReveal(params: { if (style === 'particle') { resultProps.particleLayer = 'burst ? burst.name : null'; resultProps.particleBurstAdded = 'particleFxOk'; + resultProps.logoBoundsExpandedBy = 'logoExpanded'; } script += generateResultObject(resultProps); diff --git a/src/stdio-server.ts b/src/stdio-server.ts index d60ec8b..c9e2b41 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -1090,6 +1090,7 @@ const TOOLS = [ blendMode: { type: 'string' }, parent: { type: 'number' }, is3D: { type: 'boolean' }, + collapseTransformation: { type: 'boolean', description: 'Collapse Transformations / Continuously Rasterize. On a precomp layer, effects then render in this comp\'s space instead of being clipped to the precomp\'s bounds. Not available on raster footage.' }, position: { type: 'object' }, scale: { type: 'array', items: { type: 'number' } }, rotation: { type: 'number' }, From 6c909749c55f599a50869d75152dba232007ebf2 Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:03:01 -0400 Subject: [PATCH 18/25] docs(skill): effect bounds, plugin name collisions, particle text example - Effects are clipped to their layer's bounds: use collapseTransformation for precomps, solids and vectors, and Grow Bounds (by match name) for raster footage. - apply_effect display names can resolve to a third-party plugin with the same name - "Grow Bounds" resolved to Red Giant's on a machine with it installed. Check the returned matchName, or pass one. - Particle previews should go through the render queue: a 4 s particle comp rendered in 4 s there, versus minutes frame by frame. - Example 10: a particle text reveal, the exact calls used to build the demo. Co-Authored-By: Claude Opus 5 --- SKILL/SKILL.md | 12 +++++++++--- SKILL/examples.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 397397f..cc97d5a 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -129,12 +129,12 @@ If one of these refuses with "Nothing was changed", the keyframes are untouched - `create_lower_third` - `style` (`modern`, `corporate`, `news`, `minimal`, `social`), `position` (`bottomLeft`, `bottomRight`, `bottomCenter`). **`name` is the name of the precomp it builds**; `title` is the main line and `subtitle` the second. It adds one precomp layer to the target comp. - `create_title_card` - `style`: `cinematic`, `documentary`, `social`, `minimal`. - `create_transition` - `type`: `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `dissolve`, `push`, `slide`, `zoom`; `easing`: `linear`, `easeIn`, `easeOut`, `easeInOut`. -- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`, `particle`. `particle` assembles the logo out of particles with CC Scatterize and fires a soft CC Particle Systems II burst as it lands. The burst goes on its own solid, "Logo Particles", and the result says whether it was added. +- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`, `particle`. `particle` assembles the logo out of particles with CC Scatterize and fires a soft CC Particle Systems II burst as it lands. For text, put the text in its own comp first and pass that comp as `logoItemName`. The burst goes on its own solid, "Logo Particles", and the result says whether it was added. - `create_text_animator` - needs a text layer (`layerName`); `animatorType`: `typewriter`, `fadeInChars`, `scaleInChars`, `slideInChars`, `randomize`, `wave`; optional `duration`, `delay`. ## Effects and expressions -- `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`). +- `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`) or its match name (`"ADBE Gaussian Blur 2"`). **Third-party plugins can share a display name** with a built-in effect, and the name resolves to whichever loads first. Check the `matchName` in the result, and pass the match name when it matters. - `apply_effect_template` - `template`: `gaussianBlur`, `directionalBlur`, `glassBlur`, `curves`, `colorBalance`, `brightnessContrast`, `vibrance`, `glow`, `dropShadow`, `vignette`, `cinematicLook`, `vhsRetro`, `neonGlow`, `filmGrain`, `chromaticAberration`, `duotone`; optional `intensity`. - `apply_expression_template` - `template`: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut`, `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`, `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`, `bounce`, `inertia`, `overshoot`, `springy`. Pass settings in `params`: `wiggle` takes `frequency` and `amplitude`; `bounce` takes `amplitude`, `frequency` and `decay`; `springy` takes `mass`, `stiffness` and `damping`. Unknown keys are ignored. - **Effect parameter lists are flat and reuse names.** Particle Playground has a dozen `Position`, `Affects`, `Min` and `Max` entries; CC Particle World has two `Distance`s. A display-name path such as `Effects/Particle Playground/Position` silently hits the first one. Use the parameter's match name as the last path segment instead: `Effects/CC Particle Systems II/CC Particle Systems II-0001`. @@ -150,7 +150,13 @@ CC Particle Systems II behaves in ways that aren't obvious (match names in brack - **Particle Type (`-0018`) is a numbered menu**: 1 lines (the default), 2 stars, 4 shaded spheres, **5 faded spheres (soft dots)**, 7 bubbles, 10-15 polygon shards, 17-19 large bokeh lenses. 3, 8 and 16 are menu separators and render almost nothing. - Birth Rate (`-0001`) runs 0-1000 and is continuous. For a burst, keyframe it on and off with HOLD keyframes. -Check a particle setup with `render_frame` before calling it done - these parameters are hard to judge any other way. +**Effects are clipped to their layer's bounds.** Scatterize or a glow on a tight precomp fills that rectangle and stops. To give an effect room: +- **Precomps, solids and vector layers:** `modify_layer` with `collapseTransformation: true` renders the layer's effects in the parent comp's space. +- **Raster footage (PNG, JPG, video)** can't collapse. Add `apply_effect(effect="ADBE GROW BOUNDS")` *before* the effect that needs room, and set its Pixels (`Effects/ADBE GROW BOUNDS/ADBE GROW BOUNDS-0001`). Use the match name: Red Giant's plugin is also called "Grow Bounds", and the display name picks whichever loads first. + +`create_logo_reveal` style `particle` does this automatically and reports which method it used in `logoBoundsExpandedBy`. + +Check a particle setup with `render_frame` before calling it done - these parameters are hard to judge any other way. For a full preview, **render through the render queue** rather than frame by frame: a single particle frame has to re-simulate from time 0, so 30 frames via `render_frame` took minutes, while a 4-second comp rendered through the queue in 4 seconds. ## Rendering diff --git a/SKILL/examples.md b/SKILL/examples.md index 278b61e..81bddcf 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -241,6 +241,45 @@ Avoid `control_render(action="start")` for anything long - it blocks After Effec --- +## 10. Particle text reveal + +Text assembling out of particles, then a sparkle burst as it lands. The reveal treats a comp as its "logo", so the text goes in its own tight comp first. + +``` +# 1. The text, in a comp just big enough to hold it +create_composition(name="Particle Text - Source", width=1500, height=340, frameRate=30, duration=4) +add_text_layer(compName="Particle Text - Source", name="Title", text="MOTION", fontSize=240, + justification="CENTER", color={"r": 1, "g": 1, "b": 1}, + position={"x": 750, "y": 255}) + +# 2. The main comp and the reveal +create_composition(name="Particle Text Reveal", width=1920, height=1080, frameRate=30, duration=4) +create_logo_reveal(compName="Particle Text Reveal", logoItemName="Particle Text - Source", + style="particle", duration=4, + backgroundColor={"r": 0.03, "g": 0.035, "b": 0.07}) +# -> logoBoundsExpandedBy: "collapseTransformation", particleBurstAdded: true + +# 3. A slow push-in so the end frame doesn't sit dead +set_keyframe(compName="Particle Text Reveal", layerName="Particle Text - Source", + property="scale", time=0, value=[92, 92]) +set_keyframe(compName="Particle Text Reveal", layerName="Particle Text - Source", + property="scale", time=4, value=[100, 100]) + +set_active_composition(compName="Particle Text Reveal") + +# 4. Preview - through the render queue, not frame by frame +add_to_render_queue(compName="Particle Text Reveal", + outputPath="D:/Previews/particle_text_reveal.mp4", + renderSettingsTemplate="Best Settings", + outputModuleTemplate="H.264 - Match Render Settings - 15 Mbps") +control_render(action="start") # a 4 s comp takes a few seconds +remove_from_render_queue(all=true) +``` + +Timeline: the text fades in as a particle cloud, forms letters by about 1.7 s, lands at 2 s (half the duration) with the burst, and holds from there. The source comp layer gets Collapse Transformations automatically, so the scatter isn't clipped to the 1500x340 box. + +--- + ## Timing and easing | Use | Duration | Easing | From a6ae752f55fb4c542bf16d696beab1f8b830691b Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:10:22 -0400 Subject: [PATCH 19/25] docs(skill): don't promise named, single-step undo The skill said every tool call is exactly one undo step named for the operation. That depends on the double-undo-group fix, which is left to PR #4 rather than duplicated here - and #4's design names the group after the command ID. Describe only what holds either way. Co-Authored-By: Claude Opus 5 --- SKILL/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index cc97d5a..5b593c5 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -13,7 +13,7 @@ For worked recipes, read `examples.md` in this skill. - **Ask before calling tools that change the open project as a whole**: `save_project`, `close_project`, `open_project`, `create_project`, `reduce_project`, `collect_files`, `organize_project_items`, `import_footage`, `import_folder`, `replace_footage`. The user usually has real work open. - **Look before you edit.** Call `list_compositions`, `list_layers` or `get_layer_info` rather than guessing names. -- **Every tool call is one undo step**, named for the operation ("Undo Create Lower Third"). Prefer a few purposeful calls over many tiny ones, so the user can undo cleanly. +- **Each tool call runs inside an undo group.** Prefer a few purposeful calls over many tiny ones, so the user can undo cleanly. - Times are in **seconds**. Colours are **0-1** RGB objects: `{"r": 1, "g": 0.5, "b": 0}`. ## Property paths From e74eeac4196dc093007a3b32d94621a3b50718df Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:27:21 -0400 Subject: [PATCH 20/25] fix(expressions): repair physics templates; add speed control and wiggle variants Measured on AE 26.5 with keyframes at 0, 1 and 2 s, the physics templates had these problems: - All four (bounce, overshoot, inertia, springy) keyed off key(numKeys), so they only ever affected motion after the final keyframe - the deviation after a middle key was exactly 0. They now find the most recent key at or before the playhead, so they act after every keyframe. - overshoot did nothing: it measured value - lastKey.value, which is 0 after the last key, so the layer stopped dead. It is now a damped sine seeded with the incoming velocity. - bounce was a damped-sine overshoot, not a bounce, and wasn't divided by the angular frequency, so velocity jumped from 697 to 1291 px/s at the keyframe - a visible kink. It is now a gravity/elasticity bounce-back (elasticity, gravity, maxBounces) that rebounds off the key value. Old amplitude/frequency/decay params are ignored and defaults apply. - springy divided by the undamped frequency but oscillated at the damped one, starting about 13% slow. It now divides by the damped frequency. Incoming velocity is sampled a tenth of a frame before the key, so it's the velocity arriving at the key rather than leaving it. New templates: - speedControl: accumulates a keyframed "units per second" slider as trapezoid areas between keys. `slider * time` runs the property backwards as the speed eases off (720 -> 450 -> 0 deg); this holds at 720. - loopingWiggle: crossfades wiggle(t) with wiggle(t - loopTime), so the cycle repeats seamlessly. - wiggleOneAxis: wiggles one axis and leaves the others on their keys. The techniques follow Dan Ebberts' articles on motionscript.com (Realistic Bounce and Overshoot; Expression Speed and Frequency Control). The code is written for this project, not copied. Template values are now checked before being pasted into expression source: number params must be finite numbers (a string such as "3); app.quit(" is rejected), string params are escaped for the quotes they sit in, and followPath's progress is typed "expression" because it is inserted as raw code. add_expression_control now requires controlName. When it was missing the control was named "undefined", so expressions looking it up by name never found it. Verified live: 28/28 checks on Position and Rotation - every template acts after the middle key, no expression errors, velocity continuous through the key for overshoot/inertia/springy, bounce rebounds at elasticity x incoming speed and never passes the key, speedControl gives the exact integral, loopingWiggle is identical at 0/3/6 s. Co-Authored-By: Claude Opus 5 --- .../generators/expressionGenerators.ts | 238 ++++++++++++++++-- src/stdio-server.ts | 11 +- 2 files changed, 225 insertions(+), 24 deletions(-) diff --git a/src/ae-integration/generators/expressionGenerators.ts b/src/ae-integration/generators/expressionGenerators.ts index 3428612..788d4cc 100644 --- a/src/ae-integration/generators/expressionGenerators.ts +++ b/src/ae-integration/generators/expressionGenerators.ts @@ -14,6 +14,20 @@ import { generateResultObject } from './helpers.js'; +// Finds the most recent keyframe at or before `time` (n = 0 if none), and the +// time since it. Shared by the physics templates. +const PREV_KEY = [ + 'var n = 0;', + 'if (numKeys > 0) {', + ' n = nearestKey(time).index;', + ' if (key(n).time > time) { n--; }', + '}', + 'var t = (n > 0) ? time - key(n).time : 0;' +].join('\n'); + +// Velocity arriving at key n: sampled a tenth of a frame before it. +const V_IN = 'velocityAtTime(key(n).time - thisComp.frameDuration / 10)'; + // Expression templates library const EXPRESSION_TEMPLATES: Record 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var v = velocityAtTime(key(numKeys).time - 0.001);\n value + v * amplitude * Math.sin(frequency * t * 2 * Math.PI) / Math.exp(decay * t);\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Bouncy overshoot after keyframes', + // Physics expressions. + // + // These all react to the most recent keyframe at or before the playhead + // (PREV_KEY), so they act after EVERY keyframe. The originals keyed off + // key(numKeys) and only ever affected the motion after the final keyframe. + // Incoming velocity is sampled a tenth of a frame before the key, so it is + // the velocity arriving at the key rather than leaving it. + overshoot: { + // Damped sine, divided by the angular frequency w so the wobble starts at + // exactly the incoming velocity - no kink at the keyframe. The original + // measured value - lastKey.value, which is 0 after the last key, so it + // did nothing. + expression: [ + PREV_KEY, + 'var freq = {{frequency}};', + 'var decay = {{decay}};', + 'if (n > 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' var w = freq * Math.PI * 2;', + ' value + v * Math.sin(w * t) / Math.exp(decay * t) / w;', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Wobbles past each keyframe and settles, continuing the incoming velocity', params: { - amplitude: { type: 'number', default: 0.1, description: 'Bounce amplitude' }, - frequency: { type: 'number', default: 3, description: 'Bounce frequency' }, - decay: { type: 'number', default: 5, description: 'Decay rate' } + frequency: { type: 'number', default: 3, description: 'Oscillations per second. Higher = tighter, smaller overshoot' }, + decay: { type: 'number', default: 5, description: 'How quickly the wobble dies away' } } }, - inertia: { - expression: 'var friction = {{friction}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var v = velocityAtTime(key(numKeys).time - 0.001);\n value + v * (1 - Math.exp(-friction * t)) / friction;\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Inertia/momentum after keyframes', + bounce: { + // Bounce-back: the property rebounds off the keyframe value like a ball + // off a floor - gravity pulls it back, each rebound keeps `elasticity` of + // the speed, and rebounds get shorter and more frequent as energy is lost. + // The original was a damped-sine overshoot under this name. + expression: [ + PREV_KEY, + 'var e = {{elasticity}};', + 'var g = {{gravity}};', + 'var nMax = {{maxBounces}};', + 'if (n > 0 && t > 0) {', + ' var v = -' + V_IN + ' * e;', + ' var isArr = (v instanceof Array);', + ' var vl = isArr ? length(v) : Math.abs(v);', + ' var vu = isArr ? (vl > 0 ? normalize(v) : v) : (v < 0 ? -1 : 1);', + ' var tCur = 0;', + ' var seg = 2 * vl / g;', + ' var tNext = seg;', + ' var nb = 1;', + ' while (tNext < t && nb <= nMax) { vl *= e; seg *= e; tCur = tNext; tNext += seg; nb++; }', + ' if (nb <= nMax) {', + ' var d = t - tCur;', + ' value + vu * d * (vl - g * d / 2);', + ' } else {', + ' value;', + ' }', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Rebounds off each keyframe value under gravity, like a ball hitting a floor', params: { - friction: { type: 'number', default: 5, description: 'Friction coefficient' } + elasticity: { type: 'number', default: 0.7, description: 'Fraction of speed kept on each rebound (0-1)' }, + gravity: { type: 'number', default: 5000, description: 'Pull back toward the keyframe value, in units per second squared' }, + maxBounces: { type: 'number', default: 9, description: 'Stop after this many rebounds' } } }, - overshoot: { - expression: 'var frequency = {{frequency}};\nvar decay = {{decay}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var startVal = key(numKeys).value;\n var endVal = value;\n var delta = endVal - startVal;\n endVal + delta * Math.sin(frequency * t * Math.PI) * Math.exp(-decay * t);\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Overshoot animation effect', + inertia: { + // Coasts on in the incoming direction and slows to a stop. + expression: [ + PREV_KEY, + 'var friction = {{friction}};', + 'if (n > 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' value + v * (1 - Math.exp(-friction * t)) / friction;', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Drifts on past each keyframe in the direction of travel and eases to a stop', params: { - frequency: { type: 'number', default: 3, description: 'Oscillation frequency' }, - decay: { type: 'number', default: 5, description: 'Decay rate' } + friction: { type: 'number', default: 5, description: 'How quickly the drift stops' } } }, springy: { - expression: 'var mass = {{mass}};\nvar stiffness = {{stiffness}};\nvar damping = {{damping}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var omega = Math.sqrt(stiffness / mass);\n var zeta = damping / (2 * Math.sqrt(mass * stiffness));\n var amplitude = velocityAtTime(key(numKeys).time - 0.001) / omega;\n if (zeta < 1) {\n var omegaD = omega * Math.sqrt(1 - zeta * zeta);\n value + amplitude * Math.exp(-zeta * omega * t) * Math.sin(omegaD * t);\n } else {\n value;\n }\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Spring physics simulation', + // Underdamped spring. Divides by the DAMPED frequency wd, which is the one + // it oscillates at, so it starts at the incoming velocity. The original + // divided by the undamped omega and started about 13% slow. + expression: [ + PREV_KEY, + 'var mass = {{mass}};', + 'var stiffness = {{stiffness}};', + 'var damping = {{damping}};', + 'if (n > 0 && t > 0) {', + ' var omega = Math.sqrt(stiffness / mass);', + ' var zeta = damping / (2 * Math.sqrt(mass * stiffness));', + ' if (zeta < 1) {', + ' var wd = omega * Math.sqrt(1 - zeta * zeta);', + ' var v = ' + V_IN + ';', + ' value + v / wd * Math.exp(-zeta * omega * t) * Math.sin(wd * t);', + ' } else {', + ' value;', + ' }', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Spring past each keyframe (no effect when critically or over-damped)', params: { mass: { type: 'number', default: 1, description: 'Mass' }, stiffness: { type: 'number', default: 100, description: 'Spring stiffness' }, - damping: { type: 'number', default: 10, description: 'Damping coefficient' } + damping: { type: 'number', default: 10, description: 'Damping. Must stay below 2 * sqrt(mass * stiffness) to oscillate' } + } + }, + + // Speed control + speedControl: { + // Integrates a keyframed "units per second" slider over time. Multiplying + // the slider by time instead makes the property run BACKWARDS whenever the + // speed eases off, because expressions have no memory of earlier frames. + // Areas are exact for linear and hold keyframes; eased keys approximate. + expression: [ + 'var s = effect("{{controlName}}")("ADBE Slider Control-0001");', + 'var mult = {{multiplier}};', + 'var acc = 0;', + 'if (s.numKeys > 0) {', + ' acc = Math.max(0, Math.min(time, s.key(1).time) - inPoint) * s.key(1).value;', + ' var k = 1;', + ' while (k < s.numKeys && s.key(k + 1).time <= time) {', + ' acc += (s.key(k).value + s.key(k + 1).value) / 2 * (s.key(k + 1).time - s.key(k).time);', + ' k++;', + ' }', + ' if (time > s.key(k).time) {', + ' acc += (s.key(k).value + s.valueAtTime(time)) / 2 * (time - s.key(k).time);', + ' }', + '} else {', + ' acc = s.value * Math.max(0, time - inPoint);', + '}', + 'acc *= mult;', + 'if (value instanceof Array) {', + ' var out = [];', + ' for (var i = 0; i < value.length; i++) { out.push(value[i] + acc); }', + ' out;', + '} else {', + ' value + acc;', + '}' + ].join('\n'), + description: 'Accumulates a keyframed speed slider over time, so slowing down never reverses the motion. Add a Slider Control first (add_expression_control)', + params: { + controlName: { type: 'string', default: 'Speed', description: 'Name of the Slider Control on this layer holding units per second' }, + multiplier: { type: 'number', default: 1, description: 'Scales the accumulated amount (e.g. to convert units)' } + } + }, + + // Wiggle variants + loopingWiggle: { + // Crossfades a wiggle sampled at t with one sampled at t - loopTime, so the + // end of each cycle lands exactly on its start. + expression: [ + 'var freq = {{frequency}};', + 'var amp = {{amplitude}};', + 'var loopTime = {{loopTime}};', + 'var t = time % loopTime;', + 'var w1 = wiggle(freq, amp, 1, 0.5, t);', + 'var w2 = wiggle(freq, amp, 1, 0.5, t - loopTime);', + 'linear(t, 0, loopTime, w1, w2);' + ].join('\n'), + description: 'Wiggle that repeats seamlessly every loopTime seconds', + params: { + frequency: { type: 'number', default: 2, description: 'Oscillations per second' }, + amplitude: { type: 'number', default: 50, description: 'Maximum deviation' }, + loopTime: { type: 'number', default: 3, description: 'Loop length in seconds' } + } + }, + wiggleOneAxis: { + expression: [ + 'var ax = {{axis}};', + 'var w = wiggle({{frequency}}, {{amplitude}});', + 'if (value instanceof Array) {', + ' var r = [];', + ' for (var i = 0; i < value.length; i++) { r.push(i === ax ? w[i] : value[i]); }', + ' r;', + '} else {', + ' w;', + '}' + ].join('\n'), + description: 'Wiggle along one axis only; the other axes keep their keyframed values', + params: { + axis: { type: 'number', default: 0, description: '0 = x, 1 = y, 2 = z' }, + frequency: { type: 'number', default: 2, description: 'Oscillations per second' }, + amplitude: { type: 'number', default: 50, description: 'Maximum deviation' } } } }; @@ -195,7 +368,25 @@ function processExpressionTemplate( ? params[paramName] : paramDef.default; const placeholder = '{{' + paramName + '}}'; - expression = expression.split(placeholder).join(String(value)); + // Values are pasted into expression source, so check them first: + // - number: must be a finite number, not arbitrary text; + // - string: sits inside double quotes, so escape it; + // - expression: deliberately inserted as raw code. + let replacement: string; + if (paramDef.type === 'number') { + const num = typeof value === 'number' ? value : Number(value); + if (typeof value === 'boolean' || value === '' || !isFinite(num)) { + throw new Error( + 'Template "' + templateName + '" parameter "' + paramName + '" must be a number, got: ' + JSON.stringify(value) + ); + } + replacement = String(num); + } else if (paramDef.type === 'string') { + replacement = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } else { + replacement = String(value); + } + expression = expression.split(placeholder).join(replacement); } } @@ -324,6 +515,11 @@ export function generateAddExpressionControl(params: { controlName: string; defaultValue?: number | number[] | boolean | string; }): string { + // Without this, a missing name was written as the literal text "undefined", + // so expressions looking the control up by name could never find it. + if (typeof params.controlName !== 'string' || params.controlName === '') { + throw new Error('add_expression_control requires controlName - expressions find the control by this name'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); diff --git a/src/stdio-server.ts b/src/stdio-server.ts index c9e2b41..3875d64 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -1431,7 +1431,7 @@ const TOOLS = [ }, { name: 'apply_expression_template', - description: 'Apply a pre-built expression template', + description: 'Apply a pre-built expression template. The physics templates (overshoot, bounce, inertia, springy) act after every keyframe on the property. speedControl needs a Slider Control on the layer (see add_expression_control).', inputSchema: { type: 'object', properties: { @@ -1444,13 +1444,18 @@ const TOOLS = [ type: 'string', enum: [ 'wiggle', 'wiggleSmooth', 'wiggleFadeIn', 'wiggleFadeOut', + 'loopingWiggle', 'wiggleOneAxis', 'loopCycle', 'loopPingpong', 'loopOffset', 'loopContinue', 'time', 'clock', 'countdown', 'frameNumber', 'matchPosition', 'offsetPosition', 'inverseRotation', 'followPath', - 'bounce', 'inertia', 'overshoot', 'springy' + 'overshoot', 'bounce', 'inertia', 'springy', + 'speedControl' ] }, - params: { type: 'object', description: 'Template parameters' } + params: { + type: 'object', + description: 'Template parameters. overshoot: frequency, decay. bounce: elasticity (0-1), gravity, maxBounces. inertia: friction. springy: mass, stiffness, damping. speedControl: controlName (slider name, units per second), multiplier. loopingWiggle: frequency, amplitude, loopTime. wiggleOneAxis: axis (0 x, 1 y, 2 z), frequency, amplitude. Numeric parameters must be numbers.' + } }, required: ['property', 'template'] }, From c4f61ea799f93f4b4ab9fba1c7e891bff8e1d5fa Mon Sep 17 00:00:00 2001 From: Jrocchetti <211041441+Jrocchetti@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:27:21 -0400 Subject: [PATCH 21/25] docs(skill): document the repaired physics templates and new ones - Lists every expression template with its params, grouped. bounce's params are now elasticity, gravity and maxBounces. - Explains that the physics templates act after every keyframe and take their size from the incoming speed, so an easy-eased landing key gets no effect. - speedControl, with the add_expression_control call it needs. - Example 1's bounce now keyframes only the fall and uses gravity 1500: verified to give an ~80 px first rebound and seven in all. At 5000 the first rebound was only 24 px. - Example 11: a speed ramp that doesn't rewind, verified to give 180 / 540 / 720 / 720 degrees at 1-4 s. Co-Authored-By: Claude Opus 5 --- SKILL/SKILL.md | 12 +++++++++++- SKILL/examples.md | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 5b593c5..67529cf 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -136,7 +136,17 @@ If one of these refuses with "Nothing was changed", the keyframes are untouched - `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`) or its match name (`"ADBE Gaussian Blur 2"`). **Third-party plugins can share a display name** with a built-in effect, and the name resolves to whichever loads first. Check the `matchName` in the result, and pass the match name when it matters. - `apply_effect_template` - `template`: `gaussianBlur`, `directionalBlur`, `glassBlur`, `curves`, `colorBalance`, `brightnessContrast`, `vibrance`, `glow`, `dropShadow`, `vignette`, `cinematicLook`, `vhsRetro`, `neonGlow`, `filmGrain`, `chromaticAberration`, `duotone`; optional `intensity`. -- `apply_expression_template` - `template`: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut`, `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`, `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`, `bounce`, `inertia`, `overshoot`, `springy`. Pass settings in `params`: `wiggle` takes `frequency` and `amplitude`; `bounce` takes `amplitude`, `frequency` and `decay`; `springy` takes `mass`, `stiffness` and `damping`. Unknown keys are ignored. +- `apply_expression_template` - pass settings in `params`. Unknown keys are ignored; numeric settings must be numbers. + - Wiggle: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut` (`frequency`, `amplitude`); `loopingWiggle` repeats seamlessly (`frequency`, `amplitude`, `loopTime`); `wiggleOneAxis` wiggles one axis and leaves the others on their keyframes (`axis`: 0 x, 1 y, 2 z). + - Loops and time: `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`. + - Linking: `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`. + - **Physics - these act after every keyframe on the property:** + - `overshoot` wobbles past each key and settles (`frequency`, `decay`). Higher frequency means a smaller, tighter overshoot. + - `bounce` rebounds off each key value like a ball off a floor (`elasticity` 0-1, `gravity`, `maxBounces`). For a dropped object, keyframe only the fall and let the template add the rebounds. + - `inertia` drifts on past each key and eases to a stop (`friction`). + - `springy` springs past each key (`mass`, `stiffness`, `damping`; damping must stay below 2 x sqrt(mass x stiffness) or nothing happens). + - All four take their size from how fast the property arrives at the key, so a key with no incoming motion gets no effect - including a key with easy ease on its incoming side. + - `speedControl` accumulates a keyframed speed slider (units per second) over time, so easing the speed down slows the property instead of running it backwards - the trap with `slider * time`. Add the slider first with `add_expression_control(controlType="slider", controlName="Speed")`; `params`: `controlName`, `multiplier`. Exact for linear and hold keys, approximate for eased ones. On array properties such as Scale it adds to every dimension. - **Effect parameter lists are flat and reuse names.** Particle Playground has a dozen `Position`, `Affects`, `Min` and `Max` entries; CC Particle World has two `Distance`s. A display-name path such as `Effects/Particle Playground/Position` silently hits the first one. Use the parameter's match name as the last path segment instead: `Effects/CC Particle Systems II/CC Particle Systems II-0001`. - `link_properties` - `offset` is **added** to the linked value. It shifts a layer; it cannot scale motion. For parallax or other multipliers, write the expression with `set_expression`. - In your own expressions, use `value` for the pre-expression value and `thisComp.layer("Name")` to reference other layers. diff --git a/SKILL/examples.md b/SKILL/examples.md index 81bddcf..87eba97 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -30,14 +30,20 @@ set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1, valu set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1.1, value=[100, 100]) ``` -Alternative - let an expression do the bounce after the last key: +Alternative - keyframe only the fall and let the `bounce` template add the rebounds. Each rebound keeps `elasticity` of the speed, and they get shorter as it loses energy: ``` +set_keyframe(compName="Ball", layerName="Ball", property="position", time=0, value=[960, 200]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1, value=[960, 900]) apply_expression_template(compName="Ball", layerName="Ball", property="position", template="bounce", - params={"amplitude": 0.1, "frequency": 3, "decay": 5}) + params={"elasticity": 0.7, "gravity": 1500, "maxBounces": 9}) ``` +This linear fall lands at 700 px/s, and gives a first rebound of about 80 px and seven rebounds in all. Rebound height is speed² / (2 × gravity): lower gravity or a faster landing gives bigger bounces. **Don't easy-ease the landing key** - the ball would arrive at zero speed and not bounce at all. + +For a settle-and-wobble instead of a bounce, use `template="overshoot"` with `params={"frequency": 3, "decay": 5}`. + --- ## 2. Kinetic typography @@ -280,6 +286,29 @@ Timeline: the text fades in as a particle cloud, forms letters by about 1.7 s, l --- +## 11. Speed ramp without the rewind + +Spinning something from a keyframed speed. Multiplying the slider by `time` makes it spin backwards as soon as the speed eases off; `speedControl` accumulates the speed instead. + +``` +add_null_layer(compName="Scene", name="Wheel") +add_expression_control(compName="Scene", layerName="Wheel", + controlType="slider", controlName="Speed") + +# Speed in degrees per second: spin up, hold, spin down +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=0, value=0) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=1, value=360) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=2, value=360) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=3, value=0) + +apply_expression_template(compName="Scene", layerName="Wheel", property="rotation", + template="speedControl", params={"controlName": "Speed"}) +``` + +Rotation runs 0 → 180° at 1 s → 540° at 2 s → 720° at 3 s, then holds at 720°. + +--- + ## Timing and easing | Use | Duration | Easing | From ad742438143d174d7c7bbe7c9c97b445e8f8c362 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:01:38 +0530 Subject: [PATCH 22/25] test: add portable test discovery and CI --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ package.json | 2 +- scripts/test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f3d4a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Node ${{ matrix.node }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: [18, 22] + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run build + - run: npm test diff --git a/package.json b/package.json index dd6b82f..79eabe4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "tsc", "dev": "tsc --watch", "start": "node dist/index.js", - "test": "tsx --test \"src/**/*.test.ts\"", + "test": "node scripts/test.mjs", "lint": "eslint src/**/*.ts", "clean": "rm -rf dist", "prepare": "npm run build" diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..2d28d3e --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,26 @@ +import { readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const require = createRequire(import.meta.url); + +// Node 18 does not expand test globs, and shell glob behavior differs on +// Windows. Pass an explicit, sorted file list to the same tsx test runner. +function testsIn(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? testsIn(path) : entry.name.endsWith('.test.ts') ? [path] : []; + }); +} + +const files = testsIn(join(root, 'src')).sort(); +if (!files.length) throw new Error('No test files found'); +const result = spawnSync(process.execPath, [require.resolve('tsx/cli'), '--test', ...files], { + cwd: root, + stdio: 'inherit' +}); +if (result.error) throw result.error; +process.exit(result.status ?? 1); From caabbaaac1b3cbc677baf5982b1070e2543512ef Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:01:38 +0530 Subject: [PATCH 23/25] fix: harden new tools and preserve expression compatibility --- SKILL/SKILL.md | 7 +- SKILL/examples.md | 4 +- .../generators/compositionGenerators.ts | 9 +- .../generators/expressionGenerators.ts | 53 +++-- .../generators/keyframeGenerators.ts | 20 +- .../generators/maskGenerators.ts | 9 +- .../generators/regressions.test.ts | 220 ++++++++++++++++++ .../generators/shapeGenerators.ts | 10 +- src/stdio-server.test.ts | 142 +++++++++++ 9 files changed, 437 insertions(+), 37 deletions(-) create mode 100644 src/ae-integration/generators/regressions.test.ts create mode 100644 src/stdio-server.test.ts diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 67529cf..337989d 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -142,11 +142,12 @@ If one of these refuses with "Nothing was changed", the keyframes are untouched - Linking: `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`. - **Physics - these act after every keyframe on the property:** - `overshoot` wobbles past each key and settles (`frequency`, `decay`). Higher frequency means a smaller, tighter overshoot. - - `bounce` rebounds off each key value like a ball off a floor (`elasticity` 0-1, `gravity`, `maxBounces`). For a dropped object, keyframe only the fall and let the template add the rebounds. + - `bounce` retains the original bouncy-overshoot parameters (`amplitude`, `frequency`, `decay`). + - `bounceBack` rebounds off each key value like a ball off a floor (`elasticity` 0-1, `gravity`, `maxBounces`). For a dropped object, keyframe only the fall and let the template add the rebounds. - `inertia` drifts on past each key and eases to a stop (`friction`). - `springy` springs past each key (`mass`, `stiffness`, `damping`; damping must stay below 2 x sqrt(mass x stiffness) or nothing happens). - - All four take their size from how fast the property arrives at the key, so a key with no incoming motion gets no effect - including a key with easy ease on its incoming side. - - `speedControl` accumulates a keyframed speed slider (units per second) over time, so easing the speed down slows the property instead of running it backwards - the trap with `slider * time`. Add the slider first with `add_expression_control(controlType="slider", controlName="Speed")`; `params`: `controlName`, `multiplier`. Exact for linear and hold keys, approximate for eased ones. On array properties such as Scale it adds to every dimension. + - All five take their size from how fast the property arrives at the key, so a key with no incoming motion gets no effect - including a key with easy ease on its incoming side. + - `speedControl` accumulates a keyframed speed slider (units per second) from the layer's `inPoint`. Add the slider first with `add_expression_control(controlType="slider", controlName="Speed")`; `params`: `controlName`, `multiplier`. Exact for linear and HOLD keys, including intervals clipped by trimming. Eased keys use a midpoint approximation; use linear/HOLD keys when accurate integration matters. On array properties such as Scale it adds to every dimension. - **Effect parameter lists are flat and reuse names.** Particle Playground has a dozen `Position`, `Affects`, `Min` and `Max` entries; CC Particle World has two `Distance`s. A display-name path such as `Effects/Particle Playground/Position` silently hits the first one. Use the parameter's match name as the last path segment instead: `Effects/CC Particle Systems II/CC Particle Systems II-0001`. - `link_properties` - `offset` is **added** to the linked value. It shifts a layer; it cannot scale motion. For parallax or other multipliers, write the expression with `set_expression`. - In your own expressions, use `value` for the pre-expression value and `thisComp.layer("Name")` to reference other layers. diff --git a/SKILL/examples.md b/SKILL/examples.md index 87eba97..e6e11fc 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -30,13 +30,13 @@ set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1, valu set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1.1, value=[100, 100]) ``` -Alternative - keyframe only the fall and let the `bounce` template add the rebounds. Each rebound keeps `elasticity` of the speed, and they get shorter as it loses energy: +Alternative - keyframe only the fall and let the `bounceBack` template add the rebounds. Each rebound keeps `elasticity` of the speed, and they get shorter as it loses energy: ``` set_keyframe(compName="Ball", layerName="Ball", property="position", time=0, value=[960, 200]) set_keyframe(compName="Ball", layerName="Ball", property="position", time=1, value=[960, 900]) apply_expression_template(compName="Ball", layerName="Ball", property="position", - template="bounce", + template="bounceBack", params={"elasticity": 0.7, "gravity": 1500, "maxBounces": 9}) ``` diff --git a/src/ae-integration/generators/compositionGenerators.ts b/src/ae-integration/generators/compositionGenerators.ts index 0e90706..e9fb39c 100644 --- a/src/ae-integration/generators/compositionGenerators.ts +++ b/src/ae-integration/generators/compositionGenerators.ts @@ -295,12 +295,14 @@ export function generateRenderFrame(params: { script += '}\n'; // Remove any previous render first. Otherwise an old file satisfies the // existence check below even when nothing new was written. - script += 'if (outFile.exists) { outFile.remove(); }\n'; + script += 'if (outFile.exists && !outFile.remove()) {\n'; + script += ' throw new Error("Cannot remove the previous frame render: " + outFile.fsName);\n'; + script += '}\n'; script += 'comp.saveFrameToPng(t, outFile);\n'; // saveFrameToPng returns BEFORE the file is on disk (measured on AE 26.5: it // appears ~50 ms later). Checking immediately reported every render as a // failure. Wait for the file to appear, then for its size to stop changing, - // so the caller never reads a half-written PNG. + // as a best-effort check for an incomplete write. script += 'var waitedMs = 0;\n'; script += 'while (!outFile.exists && waitedMs < 15000) { $.sleep(50); waitedMs += 50; }\n'; script += 'if (!outFile.exists) {\n'; @@ -312,6 +314,9 @@ export function generateRenderFrame(params: { script += ' if (outFile.length > 0 && outFile.length === lastSize) { stableChecks++; } else { stableChecks = 0; }\n'; script += ' lastSize = outFile.length;\n'; script += '}\n'; + script += 'if (stableChecks < 3 || !outFile.exists || outFile.length <= 0) {\n'; + script += ' throw new Error("Frame render did not finish writing within 15s: " + outFile.fsName);\n'; + script += '}\n'; script += generateResultObject({ success: 'true', diff --git a/src/ae-integration/generators/expressionGenerators.ts b/src/ae-integration/generators/expressionGenerators.ts index 788d4cc..b00f831 100644 --- a/src/ae-integration/generators/expressionGenerators.ts +++ b/src/ae-integration/generators/expressionGenerators.ts @@ -182,6 +182,28 @@ const EXPRESSION_TEMPLATES: Record 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' value + v * amplitude * Math.sin(frequency * t * 2 * Math.PI) / Math.exp(decay * t);', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Bouncy overshoot after each keyframe, using the original amplitude/frequency/decay parameters', + params: { + amplitude: { type: 'number', default: 0.1, description: 'Bounce amplitude' }, + frequency: { type: 'number', default: 3, description: 'Bounce frequency' }, + decay: { type: 'number', default: 5, description: 'Decay rate' } + } + }, + bounceBack: { // Bounce-back: the property rebounds off the keyframe value like a ball // off a floor - gravity pulls it back, each rebound keeps `elasticity` of // the speed, and rebounds get shorter and more frequent as energy is lost. @@ -271,23 +293,23 @@ const EXPRESSION_TEMPLATES: Record 0) {', - ' acc = Math.max(0, Math.min(time, s.key(1).time) - inPoint) * s.key(1).value;', - ' var k = 1;', - ' while (k < s.numKeys && s.key(k + 1).time <= time) {', - ' acc += (s.key(k).value + s.key(k + 1).value) / 2 * (s.key(k + 1).time - s.key(k).time);', - ' k++;', - ' }', - ' if (time > s.key(k).time) {', - ' acc += (s.key(k).value + s.valueAtTime(time)) / 2 * (time - s.key(k).time);', + 'var start = inPoint;', + 'if (time > start) {', + ' for (var k = 1; k <= s.numKeys && start < time; k++) {', + ' var end = Math.min(time, s.key(k).time);', + ' if (end > start) {', + ' acc += s.valueAtTime((start + end) / 2) * (end - start);', + ' start = end;', + ' }', ' }', - '} else {', - ' acc = s.value * Math.max(0, time - inPoint);', + ' if (start < time) { acc += s.valueAtTime((start + time) / 2) * (time - start); }', '}', 'acc *= mult;', 'if (value instanceof Array) {', @@ -298,7 +320,7 @@ const EXPRESSION_TEMPLATES: Record = {}) { + const prop = Object.assign(property, { property: () => prop }); + class CompItem { + duration = 10; + frameRate = 30; + name = 'Test'; + layer() { return prop; } + } + const comp = new CompItem(); + Object.assign(comp, extra.comp); + return vm.runInNewContext(script, { + app: { project: { activeItem: comp } }, CompItem, ...extra + }); +} + +function expression(template: string, params?: Record) { + const prop = { expression: '' }; + runGenerator(generateApplyExpressionTemplate({ + layerIndex: 1, property: 'rotation', template, params + }), prop); + return prop.expression; +} + +type SpeedKey = { time: number; value: number; hold?: boolean }; +function speedAt(keys: SpeedKey[], time: number, inPoint = 0, value: number | number[] = 0) { + const slider = { + numKeys: keys.length, + value: 10, + key: (i: number) => keys[i - 1], + valueAtTime(t: number) { + if (!keys.length) return this.value; + if (t <= keys[0].time) return keys[0].value; + for (let i = 1; i < keys.length; i++) { + const a = keys[i - 1], b = keys[i]; + if (t < b.time) { + return a.hold ? a.value : a.value + (b.value - a.value) * (t - a.time) / (b.time - a.time); + } + } + return keys[keys.length - 1].value; + } + }; + return vm.runInNewContext(expression('speedControl'), { + effect: () => () => slider, time, inPoint, value, Array + }); +} + +describe('speed integration', () => { + it('does not reinterpret a completed HOLD segment as a ramp', () => { + const keys = [{ time: 0, value: 100, hold: true }, { time: 1, value: 0 }]; + assert.equal(speedAt(keys, 0.5), 50); + assert.equal(speedAt(keys, 1), 100); + assert.equal(speedAt(keys, 2), 100); + assert.ok(Math.abs(speedAt(keys, 1 - 1e-8) - speedAt(keys, 1)) < 1e-5); + }); + + it('clips integration to inPoint, including a partially visible key interval', () => { + const keys = [{ time: 0, value: 10 }, { time: 1, value: 20 }, { time: 2, value: 30 }]; + assert.equal(speedAt(keys, 1, 1.5), 0); + assert.equal(speedAt(keys, 1.5, 1.5), 0); + assert.equal(speedAt(keys, 2, 1.5), 13.75); + assert.equal(speedAt(keys, 3, 1.5), 43.75); + assert.equal(speedAt(keys, 4, 3), 30); + }); + + it('integrates linear ramps, including partial intervals and constant tails', () => { + const keys = [{ time: 0, value: 0 }, { time: 2, value: 360 }, { time: 3, value: 0 }]; + assert.equal(speedAt(keys, 1), 90); + assert.equal(speedAt(keys, 2), 360); + assert.equal(speedAt(keys, 2.5), 495); + assert.equal(speedAt(keys, 4), 540); + assert.equal(speedAt([{ time: 2, value: 10 }], 3), 30); + assert.equal(speedAt([], 3, 1), 20); + assert.deepEqual(Array.from(speedAt([], 3, 1, [5, 10])), [25, 30]); + }); +}); + +describe('expression compatibility and escaping', () => { + it('keeps legacy bounce parameters and exposes gravity bounce separately', () => { + const templates = getExpressionTemplates(); + assert.ok(templates.bounce.params?.amplitude); + assert.ok(templates.bounce.params?.frequency); + assert.ok(templates.bounce.params?.decay); + assert.ok(templates.bounceBack.params?.gravity); + const source = expression('bounce', { amplitude: 0, frequency: 4, decay: 6 }); + const result = vm.runInNewContext(source, { + numKeys: 1, key: () => ({ time: 0 }), nearestKey: () => ({ index: 1 }), + time: 0.1, value: 42, velocityAtTime: () => 100, thisComp: { frameDuration: 1 / 30 } + }); + assert.equal(result, 42); + }); + + it('escapes newlines and quotes in expression string parameters', () => { + const name = 'Speed\n"quoted"\\name'; + const source = expression('speedControl', { controlName: name }); + vm.runInNewContext(source, { + effect: (actual: string) => { + assert.equal(actual, name); + return () => ({ numKeys: 0, value: 10, valueAtTime: () => 10 }); + }, + time: 1, inPoint: 0, value: 0 + }); + }); +}); + +function keyframeProperty(times: number[]) { + const keys = times.map((time, i) => ({ time, value: i + 1 })); + const prop = { + removed: 0, + get numKeys() { return keys.length; }, + keyTime: (i: number) => keys[i - 1].time, + keyValue: (i: number) => keys[i - 1].value, + keyInInterpolationType: () => 1, keyOutInterpolationType: () => 1, + keyInTemporalEase: () => [], keyOutTemporalEase: () => [], + removeKey(i: number) { this.removed++; keys.splice(i - 1, 1); }, + addKey(time: number) { keys.push({ time, value: 0 }); return keys.length; }, + setValueAtKey(i: number, value: number) { keys[i - 1].value = value; }, + setInterpolationTypeAtKey() {}, setTemporalEaseAtKey() {} + }; + return { keys, prop }; +} + +describe('keyframe retiming safety', () => { + for (const scale of [1, 0.1]) { + it(`preserves distinct subframe keys when scaling by ${scale}`, () => { + const { prop, keys } = keyframeProperty([0, 0.001]); + runGenerator(generateScaleKeyframeTiming({ layerIndex: 1, property: 'rotation', scale }), prop); + assert.deepEqual(keys, [{ time: 0, value: 1 }, { time: 0.001 * scale, value: 2 }]); + }); + } + + for (const params of [{ scale: 2, anchorTime: 2 }, { scale: 1e308 }, { scale: 1e-300, anchorTime: 1e20 }]) { + it(`rejects invalid computed times before deleting keys: ${JSON.stringify(params)}`, () => { + const { prop } = keyframeProperty([0, 2]); + assert.throws(() => runGenerator(generateScaleKeyframeTiming({ + layerIndex: 1, property: 'rotation', ...params + }), prop), /Nothing was changed/); + assert.equal(prop.removed, 0); + }); + } + + it('rejects overflowing offsets without deleting keys', () => { + const { prop } = keyframeProperty([1e308]); + assert.throws(() => runGenerator(generateOffsetKeyframes({ + layerIndex: 1, property: 'rotation', offset: 1e308 + }), prop), /Nothing was changed/); + assert.equal(prop.removed, 0); + }); +}); + +function render(options: { appearsAt?: number; grows?: boolean; empty?: boolean; stale?: boolean } = {}) { + let elapsed = 0; + class File { + fsName: string; + constructor(path: string) { this.fsName = path; } + get exists() { return options.stale || elapsed >= (options.appearsAt ?? 50); } + get length() { return options.empty ? 0 : options.grows ? elapsed : 100; } + remove() { return false; } + } + class Folder { + exists = true; + fsName: string; + constructor(path: string) { this.fsName = path; } + } + return runGenerator(generateRenderFrame({ time: 0 }), {}, { + File, Folder, $: { sleep: (ms: number) => { elapsed += ms; } }, + comp: { saveFrameToPng() {} } + }); +} + +describe('frame render completion', () => { + it('returns a non-empty stable file', () => { + assert.equal(render().success, true); + assert.equal(render().bytes, 100); + }); + for (const options of [ + { appearsAt: Infinity }, { appearsAt: 14950 }, { grows: true }, { empty: true }, { stale: true } + ]) { + it(`does not report incomplete or stale frames as successful: ${JSON.stringify(options)}`, () => { + assert.throws(() => render(options)); + }); + } +}); + +describe('path and mask safety', () => { + it('validates finite vertices and matching finite tangents', () => { + assert.doesNotThrow(() => validatePathPoints([[0, 0], [1, 1]])); + assert.throws(() => validatePathPoints([[0, 0], [Infinity, 1]])); + assert.throws(() => validatePathPoints([[0, 0], [1, 1]], [[0, 0]])); + assert.throws(() => validatePathPoints([[0, 0], [1, 1]], [[0, 0], [NaN, 0]])); + }); + + for (const generate of [generateSetPathKeyframes, generateSetMaskKeyframes]) { + it(`${generate.name} rejects invalid key times before executing any script`, () => { + assert.throws(() => generate({ + layerIndex: 1, + keyframes: [ + { time: 0, vertices: [[0, 0], [1, 1]] }, + { time: Infinity, vertices: [[0, 0], [1, 1]] } + ] + }), /finite number/); + }); + } + + it('restores a mask lock even if a property update throws', () => { + const mask = { locked: true, numProperties: 1, setValue() { throw new Error('invalid feather'); } }; + assert.throws(() => runGenerator(generateSetMaskProperties({ + layerIndex: 1, maskIndex: 1, feather: -1 + }), mask), /invalid feather/); + assert.equal(mask.locked, true); + }); +}); diff --git a/src/ae-integration/generators/shapeGenerators.ts b/src/ae-integration/generators/shapeGenerators.ts index 0f6388e..6a556bb 100644 --- a/src/ae-integration/generators/shapeGenerators.ts +++ b/src/ae-integration/generators/shapeGenerators.ts @@ -142,7 +142,8 @@ export function validatePathPoints( } for (let i = 0; i < vertices.length; i++) { const v = vertices[i]; - if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== 'number' || typeof v[1] !== 'number') { + if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== 'number' || typeof v[1] !== 'number' || + !Number.isFinite(v[0]) || !Number.isFinite(v[1])) { throw new Error('vertices[' + i + '] must be a numeric [x, y] pair'); } } @@ -153,7 +154,8 @@ export function validatePathPoints( } for (let i = 0; i < t.length; i++) { const p = t[i]; - if (!Array.isArray(p) || p.length !== 2 || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + if (!Array.isArray(p) || p.length !== 2 || typeof p[0] !== 'number' || typeof p[1] !== 'number' || + !Number.isFinite(p[0]) || !Number.isFinite(p[1])) { throw new Error(label + '[' + i + '] must be a numeric [x, y] pair'); } } @@ -419,8 +421,8 @@ export function generateSetPathKeyframes(params: { } for (let i = 0; i < params.keyframes.length; i++) { const kf = params.keyframes[i]; - if (typeof kf.time !== 'number') { - throw new Error('keyframes[' + i + '].time must be a number'); + if (typeof kf.time !== 'number' || !Number.isFinite(kf.time)) { + throw new Error('keyframes[' + i + '].time must be a finite number'); } validatePathPoints(kf.vertices, kf.inTangents, kf.outTangents); } diff --git a/src/stdio-server.test.ts b/src/stdio-server.test.ts new file mode 100644 index 0000000..6deab8e --- /dev/null +++ b/src/stdio-server.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; +import * as schemas from '@modelcontextprotocol/sdk/types.js'; +import * as generators from './ae-integration/scriptGenerator.js'; +import * as errors from './ae-integration/errorHandler.js'; + +type Handler = (request: any) => Promise; +type Tool = { name: string; inputSchema: any; generator: (params: any) => string }; + +// Load the real registration/handler code with only the transport and AE IPC +// mocked. This exercises dispatch without starting AE or creating command files. +function createServer() { + const handlers = new Map(); + const scripts: string[] = []; + class Server { + setRequestHandler(schema: unknown, handler: Handler) { handlers.set(schema, handler); } + async connect() {} + } + class FileCommunicator { + async executeScript(script: string) { + new vm.Script(script); + scripts.push(script); + return { success: true, data: { accepted: true } }; + } + } + const modules: Record = { + '@modelcontextprotocol/sdk/server/index.js': { Server }, + '@modelcontextprotocol/sdk/server/stdio.js': { StdioServerTransport: class {} }, + '@modelcontextprotocol/sdk/types.js': schemas, + './ae-integration/file-communicator.js': { FileCommunicator }, + './ae-integration/scriptGenerator.js': generators, + './ae-integration/errorHandler.js': errors + }; + const source = readFileSync(new URL('./stdio-server.ts', import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source + '\nexport { TOOLS };', { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } + }).outputText; + const exports: { TOOLS?: Tool[] } = {}; + vm.runInNewContext(compiled, { + exports, + require(name: string) { + assert.ok(name in modules, 'Unexpected module dependency: ' + name); + return modules[name]; + }, + console: { error() {} }, + process: { exit(code: number) { throw new Error('Unexpected server exit: ' + code); } } + }); + return { + tools: exports.TOOLS!, + scripts, + list: () => handlers.get(schemas.ListToolsRequestSchema)!({}), + call: (name: string, args: unknown) => handlers.get(schemas.CallToolRequestSchema)!({ + params: { name, arguments: args } + }) + }; +} + +describe('MCP tool registration and dispatch', () => { + it('registers every generator exactly once with a matching name', async () => { + const server = createServer(); + const { tools } = await server.list(); + assert.equal(tools.length, 102); + assert.equal(new Set(tools.map((tool: Tool) => tool.name)).size, tools.length); + const normalized = (name: string) => name.replace(/_/g, '').toLowerCase(); + for (const [name, generate] of Object.entries(generators)) { + if (!name.startsWith('generate')) continue; + const matches = server.tools.filter(tool => tool.generator === generate); + assert.equal(matches.length, 1, name + ' must have exactly one registration'); + assert.equal(normalized('generate' + matches[0].name), normalized(name)); + } + for (const tool of server.tools) { + assert.equal(typeof tool.generator, 'function', tool.name); + for (const required of tool.inputSchema.required ?? []) { + assert.ok(required in tool.inputSchema.properties, tool.name + '.' + required); + } + } + }); + + const vertices = [[0, 0], [100, 0], [50, 100]]; + const cases: Record = { + create_path: { vertices }, + get_path: {}, + set_path_keyframes: { keyframes: [{ time: 0, vertices }] }, + add_shape_operator: { operator: 'trimPaths', properties: { end: 50 } }, + add_mask: { vertices }, + list_masks: {}, + get_mask_path: {}, + set_mask_path: { vertices }, + set_mask_keyframes: { keyframes: [{ time: 0, vertices }] }, + set_mask_properties: { opacity: 50, locked: true }, + delete_mask: {}, + add_to_render_queue: { outputPath: '/tmp/output.mov' }, + list_render_queue: {}, + list_render_templates: {}, + set_render_queue_item: { itemIndex: 1, render: false }, + remove_from_render_queue: { itemIndex: 1 }, + control_render: { action: 'showWindow' }, + queue_in_ame: { renderImmediately: false }, + set_comp_renderer: { renderer: 'classic' }, + get_3d_info: {}, + set_3d_layer: { position: [0, 0, 0] }, + set_material_options: { acceptsLights: true }, + set_geometry_options: { extrusionDepth: 10 }, + set_camera_options: { zoom: 1000 }, + set_light_options: { intensity: 100 }, + list_project_items: {}, + set_active_composition: { compName: 'Test' }, + set_proxy: { itemId: 1, proxyPath: '/tmp/proxy.png' }, + remove_proxy: { itemId: 1 }, + get_current_time: {}, + set_current_time: { time: 1 }, + snap_to_marker: { markerIndex: 1 }, + get_nearest_marker: { time: 1 }, + navigate_markers: { direction: 'next' }, + batch_set_expressions: { expressions: [{ property: 'ADBE Transform Group/ADBE Rotate Z', expression: 'time' }] } + }; + for (const [name, args] of Object.entries(cases)) { + it(`${name} dispatches to a syntactically valid script`, async () => { + const server = createServer(); + const params: Record = { compName: 'Test', layerIndex: 1, ...args }; + const tool = server.tools.find(tool => tool.name === name)!; + for (const required of tool.inputSchema.required ?? []) { + assert.ok(required in params, name + ' fixture missing ' + required); + } + const result = await server.call(name, params); + assert.equal(result.isError, false, JSON.stringify(result)); + assert.equal(server.scripts.length, 1); + assert.equal(JSON.parse(result.content[0].text).data.accepted, true); + }); + } + + it('rejects unknown tools and invalid generated inputs without contacting AE', async () => { + const server = createServer(); + assert.equal((await server.call('unknown_tool', {})).isError, true); + assert.equal((await server.call('add_shape_operator', { operator: 'invalid' })).isError, true); + assert.equal((await server.call('scale_keyframe_timing', { scale: 0 })).isError, true); + assert.equal(server.scripts.length, 0); + }); +}); From b7568021eb8fcb88a7617c6dd8301bed2129ff14 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:01:38 +0530 Subject: [PATCH 24/25] chore(deps): update MCP SDK to patched release --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 43f39ee..da149bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -500,9 +500,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", From f22b88aa68cbff5020bcea1a607d37a63b955b1e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:17:55 +0530 Subject: [PATCH 25/25] fix: validate new tool inputs and clarify render limitations --- SKILL/SKILL.md | 2 +- SKILL/examples.md | 6 +- package-lock.json | 2 +- package.json | 2 +- .../generators/expressionGenerators.ts | 3 +- .../generators/regressions.test.ts | 35 +++- .../generators/renderQueueGenerators.ts | 21 +-- src/stdio-server.test.ts | 161 ++++++++++++++++++ src/stdio-server.ts | 101 ++++++++++- 9 files changed, 304 insertions(+), 29 deletions(-) diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 337989d..2bfc444 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -173,7 +173,7 @@ Check a particle setup with `render_frame` before calling it done - these parame - Call `list_render_templates` first and use the user's existing presets by name. - `add_to_render_queue` - `outputPath`, `renderSettingsTemplate`, `outputModuleTemplate`, `timeSpanStart`, `timeSpanDuration`. **The output module decides the file type**: a `.mov` path with an H.264 module produces `.mp4`. Report the returned `outputPath`. -- **`control_render` with `action: "start"` blocks After Effects until the whole queue finishes**, so the call will usually time out on a real render. Prefer `queue_in_ame` (`renderImmediately: true` to start encoding), which returns straight away. Poll `list_render_queue` for status. +- **`control_render` only supports `start` and `showWindow`.** `start` is synchronous and blocks the CEP bridge until the whole queue finishes: polling and stop/pause/resume are unavailable. **A command timeout does not cancel rendering.** Do not retry or modify the queue after a timeout until AE finishes; use AE's UI to interrupt a native render. Prefer `queue_in_ame` (`renderImmediately: true` to start encoding) for long jobs. `list_render_queue` inspects the native queue before/after rendering, not AME progress. - `render_frame` saves a single PNG (`time`, optional `outputDir` and `fileName`) and waits until the file is fully written. Use it to check your work. ## Known issues diff --git a/SKILL/examples.md b/SKILL/examples.md index e6e11fc..f37e805 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -243,7 +243,7 @@ list_render_queue() # confirm status "queued" and the real outputPath queue_in_ame(renderImmediately=true) # returns immediately; AME does the encoding ``` -Avoid `control_render(action="start")` for anything long - it blocks After Effects until the queue finishes, and the call will time out. +Avoid `control_render(action="start")` for anything long: it synchronously blocks the CEP bridge until the queue finishes, so polling and stop/pause/resume are unavailable. A command timeout does not cancel rendering; do not retry or modify the queue until AE finishes. Use AE's UI to interrupt native rendering. `list_render_queue` does not track AME progress. --- @@ -278,8 +278,8 @@ add_to_render_queue(compName="Particle Text Reveal", outputPath="D:/Previews/particle_text_reveal.mp4", renderSettingsTemplate="Best Settings", outputModuleTemplate="H.264 - Match Render Settings - 15 Mbps") -control_render(action="start") # a 4 s comp takes a few seconds -remove_from_render_queue(all=true) +queue_in_ame(renderImmediately=true) # render duration is not predictable; prefer AME +# Monitor completion in AME; list_render_queue is not an AME progress monitor. ``` Timeline: the text fades in as a particle cloud, forms letters by about 1.7 s, lands at 2 s (half the duration) with the burst, and holds from there. The source comp layer gets Collapse Transformations automatically, so the scatter isn't clipped to the 1500x340 box. diff --git a/package-lock.json b/package-lock.json index da149bf..210fa7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.32.1", "winston": "^3.11.0", "zod": "^3.22.4" }, diff --git a/package.json b/package.json index 79eabe4..6a65a2d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "author": "", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.32.1", "winston": "^3.11.0", "zod": "^3.22.4" }, diff --git a/src/ae-integration/generators/expressionGenerators.ts b/src/ae-integration/generators/expressionGenerators.ts index b00f831..7c4c539 100644 --- a/src/ae-integration/generators/expressionGenerators.ts +++ b/src/ae-integration/generators/expressionGenerators.ts @@ -10,6 +10,7 @@ import { generateCompAccess, generateLayerAccess, generatePropertyAccess, + getPropertyPath, wrapInUndoGroup, generateResultObject } from './helpers.js'; @@ -702,7 +703,7 @@ export function generateBatchSetExpressions(params: { script += ' var prop' + i + ' = layer;\n'; // Navigate to property - const path = expr.property.split('/'); + const path = getPropertyPath(expr.property).split('/'); for (let j = 0; j < path.length; j++) { script += ' prop' + i + ' = prop' + i + '.property("' + escapeString(path[j]) + '");\n'; } diff --git a/src/ae-integration/generators/regressions.test.ts b/src/ae-integration/generators/regressions.test.ts index 2be6cc2..691292e 100644 --- a/src/ae-integration/generators/regressions.test.ts +++ b/src/ae-integration/generators/regressions.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import vm from 'node:vm'; -import { generateApplyExpressionTemplate, getExpressionTemplates } from './expressionGenerators.js'; +import { generateApplyExpressionTemplate, generateBatchSetExpressions, getExpressionTemplates } from './expressionGenerators.js'; +import { generateControlRender } from './renderQueueGenerators.js'; import { generateScaleKeyframeTiming, generateOffsetKeyframes } from './keyframeGenerators.js'; import { generateRenderFrame } from './compositionGenerators.js'; import { validatePathPoints, generateSetPathKeyframes } from './shapeGenerators.js'; @@ -83,7 +84,39 @@ describe('speed integration', () => { }); }); +describe('render bridge contract', () => { + it('returns a native render result only after synchronous render completes', () => { + const rq = { + numItems: 1, rendering: false, completed: false, + render() { this.rendering = true; this.completed = true; this.rendering = false; } + }; + const result = vm.runInNewContext(generateControlRender({ action: 'start' }), { + app: { project: { renderQueue: rq } } + }); + assert.equal(rq.completed, true); + assert.equal(result.rendering, false); + assert.equal(result.action, 'start'); + }); +}); + describe('expression compatibility and escaping', () => { + it('batch expressions navigate friendly aliases to the actual transform property', () => { + const prop = { expression: '' }; + const transform = { + property(name: string) { assert.equal(name, 'ADBE Position'); return prop; } + }; + class CompItem { + layer() { + return { property(name: string) { assert.equal(name, 'ADBE Transform Group'); return transform; } }; + } + } + const result = vm.runInNewContext(generateBatchSetExpressions({ + layerIndex: 1, expressions: [{ property: 'position', expression: 'value + [10, 20]' }] + }), { app: { project: { activeItem: new CompItem() } }, CompItem }); + assert.equal(result[0].success, true); + assert.equal(prop.expression, 'value + [10, 20]'); + }); + it('keeps legacy bounce parameters and exposes gravity bounce separately', () => { const templates = getExpressionTemplates(); assert.ok(templates.bounce.params?.amplitude); diff --git a/src/ae-integration/generators/renderQueueGenerators.ts b/src/ae-integration/generators/renderQueueGenerators.ts index 9f9988e..07dac1d 100644 --- a/src/ae-integration/generators/renderQueueGenerators.ts +++ b/src/ae-integration/generators/renderQueueGenerators.ts @@ -271,18 +271,19 @@ export function generateRemoveFromRenderQueue(params: { } /** - * Start, stop or pause rendering. - * - * NOTE ON 'start': RenderQueue.render() is SYNCHRONOUS. After Effects blocks - * until the whole queue finishes, so the bridge will not return a response - * until then and will usually hit its command timeout on any real render. Use - * queue_in_ame for long jobs, or start the render and poll list_render_queue. + * Start synchronous native rendering or show the Render Queue panel. + * RenderQueue.render() blocks CEP until the queue finishes: the bridge cannot + * poll, stop, pause or resume it. A command timeout does NOT cancel the render. + * Use queue_in_ame for long jobs and AE's UI to interrupt native rendering. */ export function generateControlRender(params: { action: 'start' | 'stop' | 'pause' | 'resume' | 'showWindow'; }): string { const action = params.action; - const valid = ['start', 'stop', 'pause', 'resume', 'showWindow']; + if (['stop', 'pause', 'resume'].indexOf(action) !== -1) { + throw new Error('The CEP bridge cannot stop, pause or resume a synchronous native render. Use After Effects UI controls or queue_in_ame for long jobs.'); + } + const valid = ['start', 'showWindow']; if (valid.indexOf(action) === -1) { throw new Error('action must be one of: ' + valid.join(', ')); } @@ -295,12 +296,6 @@ export function generateControlRender(params: { if (action === 'start') { script += 'if (rq.numItems === 0) { throw new Error("Render queue is empty"); }\n'; script += 'rq.render();\n'; - } else if (action === 'stop') { - script += 'rq.stopRendering();\n'; - } else if (action === 'pause') { - script += 'rq.pauseRendering(true);\n'; - } else if (action === 'resume') { - script += 'rq.pauseRendering(false);\n'; } else { script += 'rq.showWindow(true);\n'; } diff --git a/src/stdio-server.test.ts b/src/stdio-server.test.ts index 6deab8e..6ae4b68 100644 --- a/src/stdio-server.test.ts +++ b/src/stdio-server.test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; import * as schemas from '@modelcontextprotocol/sdk/types.js'; +import * as validator from '@modelcontextprotocol/sdk/validation/ajv'; import * as generators from './ae-integration/scriptGenerator.js'; import * as errors from './ae-integration/errorHandler.js'; @@ -30,6 +31,7 @@ function createServer() { '@modelcontextprotocol/sdk/server/index.js': { Server }, '@modelcontextprotocol/sdk/server/stdio.js': { StdioServerTransport: class {} }, '@modelcontextprotocol/sdk/types.js': schemas, + '@modelcontextprotocol/sdk/validation/ajv': validator, './ae-integration/file-communicator.js': { FileCommunicator }, './ae-integration/scriptGenerator.js': generators, './ae-integration/errorHandler.js': errors @@ -132,6 +134,165 @@ describe('MCP tool registration and dispatch', () => { }); } + const layerTargets = [ + 'get_path', 'set_path_keyframes', 'add_shape_operator', 'set_3d_layer', + 'set_material_options', 'set_geometry_options', 'set_camera_options', 'set_light_options', + 'add_mask', 'list_masks', 'get_mask_path', 'set_mask_path', 'set_mask_keyframes', + 'set_mask_properties', 'delete_mask', 'batch_set_expressions' + ]; + for (const name of layerTargets) { + it(`${name} advertises and validates either layer selector before IPC`, async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === name)!.inputSchema; + assert.deepEqual(JSON.parse(JSON.stringify(schema.anyOf)), [ + { required: ['layerIndex'] }, { required: ['layerName'] } + ]); + const validate = new validator.AjvJsonSchemaValidator().getValidator(schema); + for (const selector of [{ layerIndex: 1 }, { layerName: 'Target' }]) { + const args = { ...cases[name], ...selector }; + assert.equal(validate(args).valid, true); + assert.equal((await server.call(name, args)).isError, false); + } + server.scripts.length = 0; + for (const selector of [{}, { layerIndex: 0 }, { layerIndex: -1 }, { layerIndex: 1.5 }, + { layerIndex: '1' }, { layerName: '' }, { layerName: ' ' }]) { + const args = { ...cases[name], ...selector }; + assert.equal(validate(args).valid, false); + const result = await server.call(name, args); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /Invalid arguments for/); + } + assert.equal(server.scripts.length, 0); + }); + } + + for (const name of ['set_proxy', 'remove_proxy']) { + it(`${name} requires a valid project item selector before IPC`, async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === name)!.inputSchema; + assert.deepEqual(JSON.parse(JSON.stringify(schema.anyOf)), [ + { required: ['itemId'] }, { required: ['itemName'] } + ]); + for (const selector of [{ itemId: 1 }, { itemName: 'Footage' }]) { + assert.equal((await server.call(name, { proxyPath: '/tmp/proxy.png', ...selector })).isError, false); + } + server.scripts.length = 0; + for (const selector of [{}, { itemId: 0 }, { itemId: 1.5 }, { itemName: '' }, { itemName: ' ' }]) { + assert.equal((await server.call(name, { proxyPath: '/tmp/proxy.png', ...selector })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + const colorCases = [ + ['create_path', 'fillColor'], ['create_path', 'strokeColor'], + ['add_mask', 'color'], ['set_mask_properties', 'color'], + ['set_material_options', 'shadowColor'], ['set_light_options', 'color'] + ]; + for (const [name, field] of colorCases) { + it(`${name}.${field} validates RGB and emits a numeric color array`, async () => { + const server = createServer(); + const base = { ...cases[name], layerIndex: 1 }; + assert.equal((await server.call(name, { ...base, [field]: { r: 0, g: 0.5, b: 1 } })).isError, false); + assert.match(server.scripts[0], /\[0, 0\.5, 1\]/); + server.scripts.length = 0; + for (const color of [{}, { r: 0, g: 1 }, { r: '0', g: 0, b: 0 }, + { r: -0.1, g: 0, b: 0 }, { r: 0, g: 0, b: 1.1 }, { r: NaN, g: 0, b: 0 }, { r: 0, g: 0, b: 0, a: 'bad' }]) { + assert.equal((await server.call(name, { ...base, [field]: color })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + it('create_path validates XY/XYZ objects without requiring an existing layer', async () => { + const server = createServer(); + for (const [position, expected] of [ + [{ x: 10, y: 20 }, '[10, 20]'], [{ x: 10, y: 20, z: 30 }, '[10, 20, 30]'] + ] as const) { + assert.equal((await server.call('create_path', { vertices, position })).isError, false); + assert.ok(server.scripts[server.scripts.length - 1].includes(expected)); + } + server.scripts.length = 0; + for (const position of [{}, { x: 10 }, { y: 20 }, { x: '10', y: 20 }, { x: 10, y: 20, z: null }]) { + assert.equal((await server.call('create_path', { vertices, position })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + + for (const name of ['add_mask', 'set_mask_properties']) { + it(`${name} accepts scalar or exactly two numeric feather axes`, async () => { + const server = createServer(); + for (const [feather, expected] of [[10, '[10, 10]'], [[10, 20], '[10, 20]']] as const) { + assert.equal((await server.call(name, { layerIndex: 1, feather })).isError, false); + assert.ok(server.scripts[server.scripts.length - 1].includes(expected)); + } + server.scripts.length = 0; + for (const feather of [[], [10], [10, 20, 30], [10, '20'], '10']) { + assert.equal((await server.call(name, { layerIndex: 1, feather })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + it('set_comp_renderer schema and dispatch accept aliases or raw ADBE names only', async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === 'set_comp_renderer')!.inputSchema; + const validate = new validator.AjvJsonSchemaValidator().getValidator(schema); + for (const renderer of ['classic', 'advanced', 'cinema4d', 'ADBE Advanced 3d', 'ADBE Future Renderer']) { + assert.equal(validate({ renderer }).valid, true); + assert.equal((await server.call('set_comp_renderer', { renderer })).isError, false); + if (renderer.startsWith('ADBE ')) { + assert.ok(server.scripts[server.scripts.length - 1].includes('comp.renderer = "' + renderer + '"')); + } + } + server.scripts.length = 0; + for (const renderer of ['other', 'ADBE ', '', 1]) { + assert.equal(validate({ renderer }).valid, false); + assert.equal((await server.call('set_comp_renderer', { renderer })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + + it('control_render advertises only synchronous start/showWindow and rejects interruption before IPC', async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === 'control_render')!.inputSchema; + assert.deepEqual(Array.from(schema.properties.action.enum), ['start', 'showWindow']); + assert.equal((await server.call('control_render', { action: 'start' })).isError, false); + assert.match(server.scripts[0], /rq\.render\(\);/); + server.scripts.length = 0; + for (const action of ['stop', 'pause', 'resume']) { + const result = await server.call('control_render', { action }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /CEP bridge cannot stop, pause or resume/); + assert.throws(() => generators.generateControlRender({ action: action as 'stop' }), /CEP bridge/); + } + assert.equal(server.scripts.length, 0); + }); + + it('optional layer tools and legacy coercion remain supported', async () => { + const server = createServer(); + for (const name of ['get_3d_info', 'snap_to_marker', 'get_nearest_marker', 'navigate_markers']) { + assert.equal((await server.call(name, cases[name])).isError, false); + } + // The original keyframe generator intentionally parses numeric strings. + assert.equal((await server.call('set_keyframe', { + layerIndex: 1, property: 'position', time: '1', value: '[10,20]' + })).isError, false); + }); + + it('batch_set_expressions resolves friendly aliases while preserving raw paths', async () => { + const server = createServer(); + assert.equal((await server.call('batch_set_expressions', { + layerIndex: 1, expressions: [ + { property: 'position', expression: 'value' }, + { property: 'ADBE Transform Group/ADBE Rotate Z', expression: 'time' } + ] + })).isError, false); + assert.match(server.scripts[0], /prop0\.property\("ADBE Transform Group"\)/); + assert.match(server.scripts[0], /prop0\.property\("ADBE Position"\)/); + assert.match(server.scripts[0], /prop1\.property\("ADBE Rotate Z"\)/); + }); + it('rejects unknown tools and invalid generated inputs without contacting AE', async () => { const server = createServer(); assert.equal((await server.call('unknown_tool', {})).isError, true); diff --git a/src/stdio-server.ts b/src/stdio-server.ts index 3875d64..8b27e12 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -7,6 +7,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -29,8 +31,48 @@ const logger: Logger = { error: (msg, meta) => console.error(`[ERROR] ${msg}`, meta ? JSON.stringify(meta) : '') }; +// Shared schemas for the newly exposed integration tools only. Legacy tools +// retain their generator-level coercion of stringified numbers and arrays. +const POSITIVE_INDEX_SCHEMA = { type: 'integer', minimum: 1 }; +const NONEMPTY_NAME_SCHEMA = { type: 'string', minLength: 1, pattern: '\\S' }; +const RGB_SCHEMA = { + type: 'object', + properties: { + r: { type: 'number', minimum: 0, maximum: 1 }, + g: { type: 'number', minimum: 0, maximum: 1 }, + b: { type: 'number', minimum: 0, maximum: 1 }, + a: { type: 'number', minimum: 0, maximum: 1 } + }, + required: ['r', 'g', 'b'] +}; +const POSITION_SCHEMA = { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } }, + required: ['x', 'y'] +}; +const FEATHER_SCHEMA = { + anyOf: [ + { type: 'number' }, + { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 } + ], + description: 'Feather in pixels: scalar for both axes, or [x, y]' +}; +const LAYER_TARGETED_TOOLS = new Set([ + 'get_path', 'set_path_keyframes', 'add_shape_operator', 'set_3d_layer', + 'set_material_options', 'set_geometry_options', 'set_camera_options', 'set_light_options', + 'add_mask', 'list_masks', 'get_mask_path', 'set_mask_path', 'set_mask_keyframes', + 'set_mask_properties', 'delete_mask', 'batch_set_expressions' +]); +const INTEGRATION_TOOLS = new Set([ + ...LAYER_TARGETED_TOOLS, 'create_path', 'add_to_render_queue', 'list_render_queue', + 'list_render_templates', 'set_render_queue_item', 'remove_from_render_queue', + 'control_render', 'queue_in_ame', 'set_comp_renderer', 'get_3d_info', + 'list_project_items', 'set_active_composition', 'set_proxy', 'remove_proxy', + 'get_current_time', 'set_current_time', 'snap_to_marker', 'get_nearest_marker', 'navigate_markers' +]); + // Tool definitions -const TOOLS = [ +const TOOLS: Array string }> = [ // ============================================ // PROJECT TOOLS // ============================================ @@ -447,7 +489,7 @@ const TOOLS = [ renderer: { type: 'string', description: 'classic (no extrusion), advanced (extrusion + shadow colour), cinema4d (extrusion + reflections). A raw "ADBE ..." string is also accepted.', - enum: ['classic', 'advanced', 'cinema4d'] + anyOf: [{ enum: ['classic', 'advanced', 'cinema4d'] }, { pattern: '^ADBE .+' }] } }, required: ['renderer'] @@ -769,7 +811,7 @@ const TOOLS = [ }, { name: 'list_render_queue', - description: 'List every render queue item with its status, time span and output modules. Use this to poll progress while a render runs.', + description: 'List every render queue item with its status, time span and output modules before or after native rendering. Cannot poll during control_render start because native rendering blocks the CEP bridge.', inputSchema: { type: 'object', properties: {} }, generator: generators.generateListRenderQueue }, @@ -813,11 +855,11 @@ const TOOLS = [ }, { name: 'control_render', - description: 'Start, stop, pause or resume rendering, or show the Render Queue panel. WARNING: start is synchronous - After Effects blocks until the queue finishes, so this call will usually time out on any real render. Prefer queue_in_ame for long jobs, or start and then poll list_render_queue.', + description: 'Start synchronous native rendering or show the Render Queue panel. WARNING: start blocks the CEP bridge until rendering finishes; polling and stop/pause/resume are unavailable. A command timeout does NOT cancel rendering. Use queue_in_ame for long jobs; do not retry or modify the queue after a timeout until rendering finishes in AE.', inputSchema: { type: 'object', properties: { - action: { type: 'string', enum: ['start', 'stop', 'pause', 'resume', 'showWindow'] } + action: { type: 'string', enum: ['start', 'showWindow'] } }, required: ['action'] }, @@ -969,7 +1011,7 @@ const TOOLS = [ items: { type: 'object', properties: { - property: { type: 'string', description: 'Property name, e.g. "Position"' }, + property: { type: 'string', description: 'Friendly property alias, e.g. "position", or a raw slash-separated match-name path' }, expression: { type: 'string' } }, required: ['property', 'expression'] @@ -1880,6 +1922,38 @@ const TOOLS = [ ]; // Create tool lookup map +// Complete the advertised schemas before compiling the very same objects for +// runtime validation. Do not apply strict validation to the 67 legacy tools. +const schemaValidator = new AjvJsonSchemaValidator(); +const inputValidators = new Map>(); +for (const tool of TOOLS) { + if (!INTEGRATION_TOOLS.has(tool.name)) continue; + const schema = tool.inputSchema; + const properties = schema.properties!; + if ('layerIndex' in properties) properties.layerIndex = POSITIVE_INDEX_SCHEMA; + if ('layerName' in properties) properties.layerName = NONEMPTY_NAME_SCHEMA; + if (LAYER_TARGETED_TOOLS.has(tool.name)) { + schema.anyOf = [{ required: ['layerIndex'] }, { required: ['layerName'] }]; + } + if (tool.name === 'set_proxy' || tool.name === 'remove_proxy') { + properties.itemId = POSITIVE_INDEX_SCHEMA; + properties.itemName = NONEMPTY_NAME_SCHEMA; + schema.anyOf = [{ required: ['itemId'] }, { required: ['itemName'] }]; + } + if (tool.name === 'create_path') { + properties.fillColor = RGB_SCHEMA; + properties.strokeColor = RGB_SCHEMA; + properties.position = POSITION_SCHEMA; + } + if (tool.name === 'add_mask' || tool.name === 'set_mask_properties') { + properties.color = RGB_SCHEMA; + properties.feather = FEATHER_SCHEMA; + } + if (tool.name === 'set_material_options') properties.shadowColor = RGB_SCHEMA; + if (tool.name === 'set_light_options') properties.color = RGB_SCHEMA; + inputValidators.set(tool.name, schemaValidator.getValidator(schema)); +} + const toolMap = new Map(); TOOLS.forEach(tool => toolMap.set(tool.name, tool)); @@ -1930,8 +2004,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } try { - // Generate the script - const script = tool.generator(args as any || {}); + // Native render blocks CEP's single command channel; these operations + // cannot interrupt it. Reject even stale clients before generating/IPC. + if (name === 'control_render' && ['stop', 'pause', 'resume'].includes((args as any)?.action)) { + throw new Error('The CEP bridge cannot stop, pause or resume a synchronous native render, or poll it while it runs. Use After Effects UI controls or queue_in_ame for long jobs.'); + } + const params = args ?? {}; + const validate = inputValidators.get(name); + if (validate) { + const result = validate(params); + if (!result.valid) throw new Error(`Invalid arguments for ${name}: ${result.errorMessage}`); + } + // Generate the script only after input validation. + const script = tool.generator(params); // Execute in After Effects const result = await communicator.executeScript(script);