🐿️

[ExtendScript] After Effects用のScriptをTypescriptで開発する

2023/12/08に公開

この記事はTechCommit AdventCalendar2023の8日目の記事です。

はじめに

Adobe After Effects用のExtendScriptをTypescriptで開発できるようにする為の私の試みを解説します。OSはWindowsを想定しています。
具体的には環境構築の一案の話であり、スクリプトの書き方については解説していません。この記事のような手法はいくつか検索すると似たようなことをされている方が見つかると思うので、1つの参考にしてもらえると嬉しいです。

基本的には以下のリポジトリの中身を解説します。正直この文章を読むのが面倒な方、Typescriptの知見がある方はコード見たほうが手っ取り早い程度のレベルの話だと思いますので、もし興味があったら参考にしてみてください。After Effectsを対象にしていますが、他のAdobe製品でも応用できると思います。
そこまで詳しいわけではないので、何かアドバイスやご指摘があればお願いします。

https://github.com/kawana77b/extendscript-ts-template

モチベーション

ExtendScriptはAdobe製品を扱うようなクリエイティブ系だと使われることがあると思うのですが、レガシーすぎて補完も何もあるようなテクノロジではありません
こういったものは確かに使い手からしたら便利かもしれませんが、プログラマからすれば保守性も可読性も非常に低下するものです。そもそもインテリセンスないと何も分からん…
しかし、types-for-adobeというプロジェクトが存在し、型を配布してくれています。そしてES3形式へのトランスパイルさえ出来ればスクリプトは大体上手いこと動くことが判明し、じゃあTypescriptでやる方がいいよなあと思いました。

ExtendScriptとは

そもそもExtendScript自体がニッチすぎると思うので、これについて少し解説します。

端的に言えばAdobe製品の操作または情報の取得を行えるマクロ言語です。仕様としては非常にレガシーで、javascript ES3の亜種です。特殊な箇所としてはC言語ライクなディレクティブ機能などがあります。拡張子が.jsxなのでJSXとも言います(Reactではない笑)

Adobeのプラグインに利用する技術としては基本AEGP(C++)、CEP(Web + Node.js + JSX)、素のJSXの3種類があり、一般的なユーザーの言うCreative Cloudマーケットプレイスで配布される「プラグイン」というものはCEPを利用していることが一般的かと思います。ただし、CEPは基本的にガワをWeb+組み込みNode.jsでラップできるようなテクノロジで、Adobeソフト操作側にはやはり素のJSXを使います。

最近はUXPと言うモダンjavascriptベースの環境にPhotoshopを手始めとして明らかにAdobeが移行したがっている気配が見えますが、他の主要ソフトウェアに展開するまでにはかなりの時間がかかる気がします。

リソース

先人の日本語解説サイトも色々ある訳ですが、最も有効的なリソースは以下のドキュメントサイトです。
AdobeのデベロッパーサイトのSDKダウンロードページからもリンクされているため、公式ページだと思われます。

本題

開発環境

ExtendScript DebuggerはAdobeの提供するVS Codeのデバッグ用拡張です。これが必要な為、エディタは必然的にVS Codeが必要です。

仕組み

大雑把な仕組みとしては単純で、以下の通りになります。

  • types-for-adobeでエディタの型補完を有効にする
  • ESLintで静的解析し、コード記述時のエラー未然抑止力を向上させる
  • Typescriptは単純にES3に変換
  • rollup.jsでバンドル

Extend Scriptには#includeという他スクリプトをインポートする仕組みがありますが、それは使い手側の可搬性を下げます。今更使う必要はあまりないように思います。その為、1つにまとめた方が良いかなと思いました。

バンドル結果の例を抜粋して以下に示します。
rollupを使うと、デフォルトではコメントも残してくれる比較的素直なコードになります。結果、バグりにくいと思います。

変換例
// ...
/**
 * find an item in a collection
 * @param collection
 * @param callback
 * @returns
 */
function find(collection, callback) {
    for (var i = 1; i <= collection.length; i++) {
        var item = get(collection, i);
        if (callback(item)) {
            return item;
        }
    }
    return undefined;
}

// If the project has compositions, debug display the layer name of the first obtained compo
// Function procedural example:
function displayCompLayerNames1() {
    var comp = find(app.project.items, function (item) { return item instanceof CompItem; });
    if (comp) {
        forEach(comp.layers, function (layer) {
            $.writeln(layer.name);
        });
    }
}
displayCompLayerNames1();

設定

この部分がほとんど全てです(笑)。
上記の仕組みを実現する為に、以下のようなパッケージ導入と設定を行いました。

package.json
{
  // ...
  "devDependencies": {
    "typescript": "5.3.2",
    "@rollup/plugin-typescript": "11.1.5",
    "@typescript-eslint/eslint-plugin": "6.13.1",
    "@typescript-eslint/parser": "6.13.1",
    "tslib": "2.6.2",
    "eslint": "^8.54.0",
    "prettier": "^3.1.0",
    "rollup": "4.6.1",
    "types-for-adobe": "^7.0.12"
  },
tsconfig.json
{
  "compilerOptions": {
    "target": "ES3",
    "module": "ESNext",
    "moduleResolution": "Node",
    "noLib": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "rootDir": "./src",
    "outDir": "./dist",
    // NOTE: Typescript ES3 support will be discontinued with 5.5
    // This option suppresses warnings:
    "ignoreDeprecations": "5.0"
  },
  "include": ["src/**/*", "src/types"]
}
.eslintrc.json
{
  "env": {
    "browser": true,
    "es2021": true
  },
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-explicit-any": "off"
  }
}
rollup.config.mjs
import typescript from "@rollup/plugin-typescript";

export default {
  input: "src/index.ts",
  output: {
    dir: "dist",
    format: "cjs",
  },
  plugins: [typescript()],
};
src/types/index.d.ts
/// <reference types="types-for-adobe/AfterEffects/22.0"/>

こんな感じの設定でrollup -cすればindex.jsファイルが吐かれます。

配布方法

大したことはしません。作成したindex.jshoge.jsxファイルにリネームして、以下のことをします。

  • After Effectsの ファイル > スクリプトのインストール を選ぶ
  • C:\Program Files\Adobe\Adobe After Effects [ver]\Support Files\Scriptsに突っ込んでも良い。中にはフォルダを作っても良い

便利ポイント

作っておいて便利だなと思ったところです。

  • とにかく補完が効いて比較的モダンな構文を使える
    • クラス構文やfor...ofなど
    • 欲しい拡張メソッドはポリフィルすればOK
  • ファイルごとに目的別に関数や処理をまとめられ、必要に応じて使い回せる
    • バンドラは必要なところだけをバンドルする
    • 良し悪しはあると思いますが、コードベースで1ファイルに冗長なベタ書きは辛い
  • 発展的形態としてGithub ActionsのようなCI環境も使える(基本使わないと思いますが)
    • 別に変換自体はAdobeに依存したことをやらないので、ローカルで成果物をビルドしなくても良いとか、色々な形態を作れます

デメリットとして結局これはあくまでES3への変換であり、JSXとしての動作は絶対的に保証されては無く、どのようにトランスパイルされたかは最終的に追う必要はあることです。また、変換後関数に何が使われてるかなど、パフォーマンスなどの問題を考慮しなければならないケースも時としてあると思います。
ただ、上記のようなことは今のところ遭遇して無く、メリットの方が大きいと思います。

おまけ: 知っておくと良さそうなこと

最後におまけです。私がExtendScriptを学んでいる際に知っておくと良いと思ったことをピックアップします。

AEのExtendScriptランタイムは2つある

プロジェクト設定

特に何か遭遇した訳ではないのですが、考慮することです。After Effectsのプロジェクト設定ウインドウにはエクスプレッションの実行環境の設定があり、JavaScriptエンジンかExtendScriptエンジンかを選びます。デフォルトではJavaScriptが選択されています。
これはAdobeのエクスプレッションエンジン間の構文の違いというドキュメントに示されています。具体的には一部構文がECMA2018準拠となり、端的に言えば主に挙動が変化します。機能追加要素もあるものの、クラス構文がサポートされたみたいなことなどはなく、実質ExtendScript ver2みたいな感じです。使い手の環境を知る必要がありますが、基本的にJavaScriptが選択されていれば大凡問題がないパターンが多いでしょう。

Collectionのインデックスは1起算

ExtendScriptでは色々なコンテナのアクセスにCollectionの派生オブジェクトを利用するわけですが、そのIndex番号が1起算となっています。多くの場合スクリプトでやりたいことはバッチ的処理なので、for (var i = 1;...)のようなループが繰り返し出ることになるでしょう。なんとなくLuaを思い出すような仕様です。
こういったものこそ関数化してしまうと良いと思いますので、私は以下のような形で関数にしました。

Collection用のforEachを作成する例
/**
 * get item from a collection by index
 * @param collection
 * @param idx
 * @returns
 */
function get<T>(collection: Collection, idx: number) {
  const _t = collection as any;
  return _t[idx] as T;
}

/**
 * iterate over a collection of items
 * @param collection
 * @param callback
 */
export function forEach<T extends Collection, K>(
  collection: T,
  callback: (item: K, idx?: number) => void,
): void {
  for (let i = 1; i <= collection.length; i++) {
    const item = get<K>(collection, i);
    callback(item, i);
  }
}

AEにはポリフィルが組み込まれている

最新のAEには組み込みでJSONArrayのポリフィルが存在します。
以下の記事を見て確認したのですが、知ったときは目から鱗でした。

https://qiita.com/tetsuoh/items/186bbcad3305ffeccbc5

特にJSONについてはあるとかなり便利だと思います。
スクリプトを見るに、本来CEP用です。CCライブラリを開かず単独のJSXスクリプトを実行したい場合、動作しません。

その実体はC:\Program Files\Adobe\Adobe After Effects [ver]\Support Files\Libraries\jsxに存在するので、これを実行してやればむやみにカスタムのポリフィルを導入する必要もなく、比較的安全にスクリプトを実行できます。

ScriptUIを作れるツールがある

ScriptUI Dialog Builderというツールを制作した方がおり、UIを簡易的にGUI上で作ることができます。すごいですね。
コードをコピペしちゃえばほぼ動くので、複雑でなければかなり目的に近いUIが作れます。
これも最初見た時たまげました。

https://github.com/joonaspaakko/ScriptUI-Dialog-Builder-Joonas

Chat GPTを活用する

ポンコツな回答をすることもありますが、Chat GPTは比較的ExtendScriptも良い回答を示してくれることが多い気がします。Chat GPTってレガシーテクノロジほどポンコツ回答になりやすい印象があるのですが、まさに希望。
こんな使い方あるの!?とかこんなAPIあるの!?って部分が知れることもあります。
もし詰まったら、積極的に活用していきたいものですね。

質問例

質問:

Adobe Extend Scriptで現在アクティブなコンポのレイヤーを全て選択するスクリプトを示して

回答:
Adobe After EffectsのExtendScriptを使用して、現在アクティブなコンポジションのすべてのレイヤーを選択するスクリプトは以下の通りです。このスクリプトは、コンポジションがアクティブであることを確認し、そのコンポジション内の全てのレイヤーを選択します。

// 現在のコンポジションを取得
var activeComp = app.project.activeItem;

// コンポジションが存在し、アクティブであるか確認
if (activeComp && activeComp instanceof CompItem) {
    // コンポジション内のレイヤーを選択
    for (var i = 1; i <= activeComp.layers.length; i++) {
        activeComp.layers[i].selected = true;
    }

    // 選択を更新
    app.executeCommand(3153); // コンポジションの選択を更新
} else {
    alert("アクティブなコンポジションが見つかりません。");
}

このスクリプトでは、app.project.activeItemを使用して現在のアクティブなコンポジションを取得し、その後、そのコンポジション内のすべてのレイヤーを選択しています。最後に、app.executeCommand(3153)を使用して、選択を更新しています。

このスクリプトをAfter EffectsのExtendScriptエディタで実行すると、アクティブなコンポジション内の全てのレイヤーが選択されます。

おわりに

エキスパートならまだしも、私のような人にとってExtendScriptの仕様は非常に苦行ですが、環境さえ作れるなら工夫次第で生産性を大幅に変えることができるなと感じました。
リファレンスを.打つごとに行ったり来たりする時間は極力減らしたい。そもそもあんまりこのテクノロジに学習モチベがない笑、という人にとっては、色々便利かと思います。

Discussion