After Effectsのエクスプレッションのライブラリを考える。

上記記事の最後の方で、エクスプレッションライブラリを作るならこんな感じじゃないかというのを書いた。 それから色々思索しながら、どういうのがいいのかを探っている。

以下が経過中。

@scriptスクリプトを書く人向けに作っているように、これもエクスプレッションを書く人を想定して作っている*1。 単一のエクスプレッションをコピペするだけで済むだけなら簡単であるが、他のパラメータを参照したり、あるパラメータのエクスプレッション適用前の値を取得するなど、複雑なことをやろうとするとスクリプトを介する必要がある。そういった場合にどうすればスマートになるのか、現状、解は出ていない。

ただそうは言っても、数学だったりアルゴリズムだったりは下界を無視して構築できるし、テキストのスタイルもテキストレイヤーのソーステキストだけで完結させられるので、都合が良い。

テキストスタイル

スクリプトではバージョンの24.3、エクスプレッションでは25.0から、文字ごとにスタイルを変更することが可能となった。スクリプトとエクスプレッション、それぞれに利点があるが、エクスプレッションの場合、非破壊的に色々試せるのが一番の魅力である。重ければベイクすればいい。

使いやすくないと作る意味がないので、複雑性を如何にライブラリ側に閉じ込め、ユーザー側にはいい感じのAPIを提供する必要があるが、そこがやはり一番頭を使う。

静的

混植は、文字種によってスタイルを変える操作である。つまり、ある条件を満たしたものに対し、あるスタイルを適用する、というパターンと見なせる。

const { CharClass, TextStyle } = footage("@text.jsx").sourceData.load();

TextStyle.byCharClass()
    .rule(CharClass.Hiragana, { applyFill: true, fillColor: [0.8, 0.7, 0.2], applyStroke: false, fontSize: 45 })
    .rule([CharClass.Katakana, CharClass.Yakumono], { applyFill: false, applyStroke: true, strokeColor: [0.5, 0.6, 0.9], fontSize: 55, strokeWidth: 2 })
    .rule(CharClass.Kanji, { applyFill: true, fillColor: [0.4, 0.8, 0.3], applyStroke: false, fontSize: 60 })
    .apply();

こういったパターンの場合、宣言的に書けると分かりやすいので、現状こうなってる。

const { TextStyle } = footage("@text.jsx").sourceData.load();

TextStyle.byPosition()
    .line() // 行ごとにカウントを刷新する。
    .countWhen("nonWhitespace") // 空白はカウントしない。
    .rule((index, line) => (index + line) % 2 === 0, { applyFill: true, fillColor: [0.7, 0.8, 0.2], applyStroke: false, fontSize: 50 })
    .rule((index, line) => (index + line) % 2 === 1, { applyFill: false, applyStroke: true, strokeColor: [0.3, 0.6, 0.75], strokeWidth: 2, fontSize: 60 })
    .apply();

条件としては、文字位置や行位置、

const { TextStyle } = footage("@text.jsx").sourceData.load();

TextStyle.bySurrounding("「", "」", { depth: 0 })
    .rule({ applyFill: false, applyStroke: true, strokeColor: [0.7, 0.9, 0.8], strokeWidth: 2 })
    .apply();

はたまた括弧など、色々考えられる。

const { CharClass, TextStyle } = footage("@text.jsx").sourceData.load();

TextStyle.compose()
    .add(
        TextStyle.byCharClass()
            .rule(CharClass.Kanji, { fontSize: 100 })
    )
    .add(
        TextStyle.byPosition()
            .rule((index) => index % 2 === 0, { applyFill: true, applyStroke: false, fillColor: [0.4, 0.5, 0.8], fontSize: 40 })
    )
    .add(
        TextStyle.bySurrounding("「", "」")
            .rule({ applyFill: false, applyStroke: true, strokeColor: [0.7, 0.2, 0.6], strokeWidth: 2, fontSize: 120 })
    )
    .apply();

また、人は往々にして複数のものを見るとそれを積み上げたがる。

動的

宣言的だと、どうしても、条件とスタイルを個別、分離的に指定することになる。なので、

const { TextStyle } = footage("@text.jsx").sourceData.load();
const { Oklch } = footage("@color.jsx").sourceData.load();

TextStyle.forEachGrapheme((g, ctx) => {
    return { applyFill: true, applyStroke: false, fillColor: new Oklch([0.7, 0.1, random(ctx.index)]).toRGB().get() };
}).apply();

書記素を舐めていって、その文脈に応じてスタイルを返すことで、都度適用出来るようにした。


const { TextStyle } = footage("@text.jsx").sourceData.load();
const d = Math.floor(3 * timeToFrames(time));
TextStyle.forEachGrapheme((g, ctx) => {
    const seed = ctx.index + d;
    return {
        applyFill: noise(seed) > 0,
        fillColor: hslToRgb([0.5 * (1.0 + noise(seed + 0.5)), 0.6, 0.5, 1]).slice(0, 3),
        applyStroke: noise(seed + 1.2) > 0,
        strokeColor: hslToRgb([0.5 * (1.0 + noise(seed + 4.2)), 0.6, 0.5, 1]).slice(0, 3),
        fontSize: 30 + 70 * (1.0 + noise(seed + 1.4142)),
        tsume: 150 * (1.0 + noise(seed + 2.236)),
        baselineShift: 150 * noise(seed + 9.2),
    };
}).apply();

余談

スタイルの話

エクスプレッションで文字ごとにスタイルを設定する際、text.sourceText.style.setFillColor([1, 0, 0, position, count);のように位置と文字数を指定するのであるが、オーバーラップするような区間に対して設定する場合の優先順序がややこしい。後で適用した側が優先される仕組みではなく、範囲の指定の仕方によって優先順位が決定されるようなので、弥縫策として一文字ずつ設定するようにしてあり*2、必ず後で指定した方が優先されるようになっている。

ライブラリの話

TypeScriptの小話
{
    method() {
    },
}

のように、いきなり{を書くとブロック文だと認識されるので、

({
    method() {
    },
})

のように、 ( ) で囲んでやって、これはオブジェクトですよと主張する必要がある。幸いなことに、丸括弧で囲んでも.jsx であれば、AE側できちんと読み込んでくれる。ただし、トランスパイルをすると勝手に末尾に ; が付けられるので、これは取り除く必要がある。

あと、同じプロジェクト内でライブラリ側ではなくて、エクスプレッション側として

const L = thisComp.layer("Parent");

のようなものを複数のファイルで書くと、二重定義で怒られる。つまり、TypeScript側でグローバルだと解釈されてしまうので、

export { };

const L = thisComp.layer("Parent");

のように、空の export {}; を書いて、ファイル単位ですよとアピールする必要がある。これも、後処理で取り除きたい。

構成の小話

上のようなMotion Developerさんとこのように、一機能ずつプロジェクトを分けたり、モジュールバンドラーなどを用いるのは、AEのスクリプトやエクスプレッションの開発では、あまりにも過剰だと思っている。なので環境を出来るだけシンプルに保ちたいが、上で書いたようにトランスパイル後に ; などを取り除く必要があるので、何かしらの後処理はする必要がある。TypeScript前提の環境であるから、必然的にNode.js環境でもあるので、適当にJavaScriptで補助用のスクリプトを書くのが最適なのかな。

*1:ライブラリであるから当然のことではあるが

*2:無駄に重くする行為であるので、範囲が重複したら分割して、みたいな方向が考えられるが、要思索