コンテンツへスキップ

QualiArtsengineer blog

Photoshop UXP への移行について 後編

Photoshop UXP への移行について 後編

11 min read

はじめに

テクニカルアーティスト室所属の dockurage です。 当室では、Maya、Unity、Photoshop といったソフトウェアにおける業務効率化のためにプラグインを作成しています。 本記事では私が担当していた Photoshop Extension の UXP 移行についてのお話をしたいと思います。 以前より Photoshop のプラグイン開発では CEP(Common Extensibility Platform)を使用していましたが、次世代の開発プラットフォームである UXP(Unified Extensibility Platform)に移行を行いました。UXPの導入方法については以下の記事で紹介しておりますので、あわせてご覧ください。

Photoshop UXP への移行について 前編

今回は実際に UXP 開発でつまづいた点をお話します。

Photoshop の状態を更新する場合は core.executeAsModal が必須

Photoshop の状態を更新するような処理 (レイヤー名を変えるなど) を行う場合は、executeAsModal のスコープ内に実装しないといけない決まりがあります。スコープ外で実行するとエラーダイアログが表示されて動作しません。 ※ 状態を取得する系の処理 (レイヤー名を取得するなど) の場合は executeAsModal なしでも動作するようでした。

import { action, core } from "photoshop";

core.executeAsModal(async () => {
    // ここに Photoshop の状態を更新する処理を書く必要があります
    const command = {
        _obj: "set",
        _target: [{
            _enum: "ordinal",
            _ref: "layer",
            _value: "targetEnum"
        }],
        to: {
            _obj: "layer",
            name: "リネーム"
        }
    };
    await action.batchPlay([command], {});
}, {commandName: "test"});

Spectrum UXP Widgets

UXP での UI 制作方法は、標準の HTML 要素、組み込みの Spectrum UXP Widgets、および Spectrum Web Components の 3つがあります。 組み込みの Spectrum UXP Widgets と Spectrum Web Components の違いは、どちらも Spectrum という Adobe が提供するオープンソースのガイドラインで実装されたコンポーネント群ですが、前者は Photoshop にあらかじめ組み込まれているもので、後者は package.json で追加できるオープンソースのWebコンポーネントとのことです。 Spectrum Web Components は魅力的でしたが、移行作業中 UXP v7.0 にアップデートされた時点でベータ版として追加されたため採用は見送りました。

標準 HTMLSpectrum UXP Widgets Spectrum Web Components (Beta)
HTML / CSS / Javascript で実装する。手間はかかるが必要な機能を実装することができるあらかじめ Photoshop に組み込まれているコンポーネントを使用する。拡張できないことはないがUIの細かい挙動などできないケースもをあるpackage.json で必要なコンポーネントを個別にインストールして利用する。組み込み Spectrum UXP ウィジェットよりも種類が豊富。

Spectrum UXP Widgets は HTML のように実装を行います。

<sp-body>
    <sp-button onClick={clickHandler} />
</sp-body>

Spectrum UXP Widgets を使用していて気になったことがあります。コンポーネントによって属性の実装状況が違います。例えば JavaScript で addEventListner を使ってイベントを追加することは可能な onChange などが属性で指定できないことがあり、動作していると思い込んで動作していなかったというケースがありました。 そのため Spectrum UXP Widgets の全コンポーネントをラップした React コンポーネントを自作し、そういったつまづきを減らすようにしています。このラッパーのおかげでコンポーネントの拡張もしやすくはなったので、初期に手間はあるもののやってよかったと思っています。

ラッパークラスの実装例

import React, { forwardRef, useRef } from "react";

const ActionButton = forwardRef((props, ref) => {
    
    if (!ref) {
        ref = useRef(null);
    }

    return (
        <sp-action-button
            ref={ref}
            class={props.className}
            disabled={props.disabled}
            quiet={props.quiet}
            size={props.size}
            onClick={props.onClick}
        >
            {props.children}
        </sp-action-button>
    );
});

export default ActionButton;

実際に利用した例

sp-action-button を React コンポーネントとして利用する形になります。

import React from "react";
import SP from "../lib/spectrum";

export const SampleButton = () => {

    const onClickHandler = e => {};

    return (
        <div>
            <SP.ActionButton onClick={onClickHandler}>
                <SP.Icon name="ui:More" slot="icon"></SP.Icon>
            </SP.ActionButton>
        </div>
    )
};

useEffect を使用した onChange の追加実装例

UXP の sp-menu に onChange イベントを実装する場合は、useEffect と addEventListener にコールバック関数を登録すると対応できます。この方法で DOM の属性に onChange を追加することができました。

import React, { forwardRef, useEffect, useRef } from "react";

const Menu = forwardRef((props, ref) => {

    if (!ref) {
        ref = useRef(null);
    }

    useEffect(() => {
        const dispatchChangeHandler = e => props.onChange && props.onChange(e);
        ref.current && ref.current.addEventListener(”change”, dispatchChangeHandler);
        return () => {
            ref.current && ref.current.removeEventListener(”change”, dispatchChangeHandler);
        };
    }, [props.onChange]);
    
    return (
        <sp-menu
            ref={ref}
            class={props.className}
            slot={props.slot === ”options” ? ”options” : undefined}
            selectedIndex={props.selectedIndex}
            size={props.size}
            onClick={props.onClick}
        >
            {props.children}
        </sp-menu>
    );
});

export default Menu;

実際に利用した例

import React from "react";
import SP from "../lib/spectrum";

export const SampleMenu = () => {

    const onClickHandler = e => {};

    return (
        <div>
            <SP.Menu onChange={onChangeHandler}>
                <SP.MenuItem>Home</SP.MenuItem>
                <SP.MenuItem>Config</SP.MenuItem>
            </SP.Menu>
        </div>
    )
};

ファイルパス & ファイル出力

batchPlay で PSD にリンクされたファイルのリンク先を変更しようとした時に、問題が起きました。 ※ Photoshop では別ファイルのデータをリンクしてファイル内に配置することができます。

いつもの調子で batchPlay 向けに参考となる処理をアクションの「JavaScript としてコピー」から取得したのですが、このままでは動作せず、ファイルパスをトークンに変換しないといけないという罠がありました。 これはファイルの再リンクに限らず、Photoshop でファイルの保存などのファイルを扱う場合には、トークンを使って処理を行わないといけないようです。

「JavaScript としてコピー」で取得したスクリプト

async function actionCommands() {
    let command;
    let result;
    let psAction = require("photoshop").action;

    // ファイルに再リンク
    command = {"_obj":"placedLayerRelinkToFile","layerID":57,"null":{"_kind":"local","_path":"/Users/xxxx/sample.png"}};
    result = await psAction.batchPlay([command], {});
}

async function runModalFunction() {
    await require("photoshop").core.executeAsModal(actionCommands, {"commandName": "Action Commands"});
}

await runModalFunction();

書き換えたスクリプト

「_path」の指定部分が「/Users/xxxx/sample.png」で出力されていたので、その部分をファイルパスからトークンを取得する処理に書き換えます。

async function actionCommands() {
    // ファイルパスからトークンを取得
    let path = "file:/Users/xxxx/sample.png"
    let fs = uxp.storage.localFileSystem;
    let file = await fs.getEntryWithUrl(path);
    let token = await fs.createSessionToken(file);

    let command;
    let result;
    let psAction = require("photoshop").action;

    // ファイルに再リンク
    command = {"_obj":"placedLayerRelinkToFile","layerID":57,"null":{"_kind":"local","_path":token}};
    result = await psAction.batchPlay([command], {});
}

async function runModalFunction() {
    await require("photoshop").core.executeAsModal(actionCommands, {"commandName": "Action Commands"});
}

await runModalFunction();

ExtendScript をどうしても使いたい場合

ほとんどの実装は batchPlay で解決できることが多いのですが、稀に ExtendScript を実行したいケースがあります。UXP で未実装であったり、前編で説明した batchPlay のサンプルを取得する方法が取れない Photoshop のアクションが存在します。こういう時は ExtendScript を実行したいと考えてしまうことがありました。

Photoshop には ExtendScript を記載した外部の jsx ファイルを実行することができる機能があります。これを利用して、どうしても ExtendScript を実行したい場合に専用の処理を用意しました。その方法は以下です。

  1. ExtendScript を jsx ファイルとして作成
  2. 1 で作成した jsx ファイルをマクロとして実行

実装例

import { action, core } from "photoshop";
import { storage } from "uxp";

const extendScriptRun = async scriptString => {
    
    // - - - - - - - - - - - - 
    // Temporary に ExtendScript を記載したスクリプトファイルを生成
    // - - - - - - - - - - - - 
    const scriptName = "__script__.jsx";
    const lfs = storage.localFileSystem;
    const folder = await lfs.getTemporaryFolder();
    const file = await folder.createFile(scriptName, {overwrite: true});
    await file.write(scriptString);

    // - - - - - - - - - - - - 
    // Temporary で作成した ExtenScript ファイルをマクロとして実行
    // - - - - - - - - - - - - 
    let result = null;
    await core.executeAsModal(async () => {
        const jsxFileObject = await folder.getEntry(scriptName);
        const filetoken = await lfs.createSessionToken(jsxFileObject);
        const command = {
            _obj: "AdobeScriptAutomation Scripts",
            javaScript: { _path: filetoken, _kind: "local" },
            _isCommand: true,
            _options: { dialogOptions: "dontDisplay" }
        };
        result = await action.batchPlay([command], {});
    }, {});

    return result;
}

実際に利用した例

extendScriptRun(`Folder("/Users/xxxx/Desktop").execute();`);

Photoshop 側から指定したパスのエクスプローラー(Windows) やファインダー(Mac) を起動することが UXP では、まだできなかったため、この方法を使って実現することができました。

TGA 保存

UXP ではファイルの保存に制約があります。Photoshop で書き出せるファイルフォーマットすべてを保存できるわけではありません。対応しているのは bmp, gif, jpg, png, psb, psd です。弊社ではテクスチャのファイルフォーマットを tga にしていたので困りました。 どうやら Photoshop 2023 から tga ファイル出力がサポート対象から外れているようでした。そのせいか UXP では対象から外れているようです。※ Photoshop での旧版の出力方法は「環境設定」のオプションとして存在しており、そちらを有効にすると保存はできる状態に戻すことができるようになってはいます。また「コピーとして保存」が旧出力方法と同じようです。

そこで、上記の「ExtendScript をどうしても使いたい場合」の方法を使って TGA の保存を行っています。

起動できるファイルの種類は manifest.json の launchProcess.extensions に書いておく必要があります。※ TGA は初期値に書いてなかったので追加

{
    "requiredPermissions": {
        "allowCodeGenerationFromStrings": true,
        "localFileSystem": "fullAccess",
        "clipboard": "readAndWrite",
        "launchProcess": {
            "schemes": [
                "http",
                "https"
            ],
            "extensions": [
                ".svg",
                ".png",
                ".tga"
            ]
        },
        ...
    },
    ...
}
const extendScriptRun = async scriptString => {
    const jsxFile = await createScriptFile(scriptString);
    await core.executeAsModal(async () => {
        const pluginFolder = await lfs.getTemporaryFolder();
        const jsxFileObject = await pluginFolder.getEntry(jsxFile);
        const filetoken = await lfs.createSessionToken(jsxFileObject);
        const command = {
            _obj: "AdobeScriptAutomation Scripts",
            javaScript: { _path: filetoken, _kind: "local" },
            _isCommand: true,
            _options: { dialogOptions: "dontDisplay" }
        };
        await CommandRunner.create([command]).executeAsync();
    }, {});
};

const tga = async exportPath => {
    // TODO: UXP は TGA 非対応のため Action 経由で ExtendScript を無理矢理実行する
    const scriptname = "exportTga";
    const docId = undefined;
    let convertExportPath = PathUtils.convertPath(exportPath);
    let exportDirPath = PathUtils.getDirPath(exportPath);
    exportDirPath = PathUtils.convertPath(exportDirPath);
    createDir(exportDirPath);
    try {
        const fileName = await extendScriptRun(`
        var desc1 = new ActionDescriptor();
        var desc2 = new ActionDescriptor();
        desc2.putInteger(charIDToTypeID('BtDp'), 32);
        desc2.putInteger(charIDToTypeID('Cmpr'), 0);
        desc1.putObject(charIDToTypeID('As  '), charIDToTypeID('TrgF'), desc2);
        desc1.putPath(charIDToTypeID('In  '), new File("${convertExportPath}"));
        desc1.putString(charIDToTypeID('jsNm'), '${scriptname}');
        desc1.putString(charIDToTypeID('jsMs'), 'true');
        executeAction(stringIDToTypeID('save'), desc1, DialogModes.NO); `);
    } catch (e) {
        console.error(e);
    }
    let status = await isExistFile(`${Constants.FsProtocol.FILE}${convertExportPath}`);
    console.log(`export: ${convertExportPath}`);
    return status ? exportPath : null;
};

Windows で別ドライブへ移動しようとするとエラー

ファイルを別ドライブへ移動しようとすると fs.rename で「Error: cross-device link not permitted.」というエラーが発生してしましました。 Windows 側の問題らしくどうしようもなさそうなので copyFile して 元ファイルを unlink することで移動させるように修正しました。

import fs from "fs";

const temp = await lfs.getTemporaryFolder();
const tempPath = `file:C:\\${temp.nativePath}\\export.png`;
const exportPath = `file:D:\\export.png`;
await app.activeDocument.saveAs.png(temp);

- - - - - - - - - - - 
// Windows でエラーになります
fs.rename(tempPath, exportPath);

// fs.rename の代替えとして下記の処理に変更
// コピーして元データを削除すると問題なく動作します
fs.copyFile(tempPath, exportPath);
fs.unlink(tempPath);
- - - - - - - - - - - 

パネルの操作のコールバックがバグで動作しない

Windows 版 Photoshop では React を使用していると、Mac 版に比べ render のタイミングが遅いなど問題があったため、動作を改善すべくアクティブなパネル以外は処理をしないようにしてあります。 その実装を行うときに問題になったのが、各パネルの表示/非表示のコールバックメソッドが1度しか動作しなかったり、動作しないバグ (公式では将来修正予定とのこと) があり、パネルの状態取得が難しく対応に困りました。

Known Issues (UXP for Adobe Photoshop)

  • uxpshowpanel and the corresponding show callback occurs only once, when the panel is initially made visible. It will not recur. This will be fixed in the future. (PS-57284)
  • uxphidepanel and the corresponding hide callback never occurs, even when the panel is made invisible. This will be fixed in the future. (PS-57284)

プラグインの初期設定を行う entrypoints.setup の panels の項目にパネルの設定項目を登録した Object を設定します。ここでパネルの開閉のコールバックを設定することができるのですが、バグにより正しく動作しません。

entrypoints.setup({
    panels: {
        panelName: {
            ...
            show: function () {},
            hide: function () {}
        }
    }
});

そこで batchPlay を使ってパネルリストを取得する処理を作りました。

const getPanelList = async () => {
    const psAction = require('photoshop').action;
    const result = await psAction.batchPlay([{
        _obj: "get", 
        _target: [
            { _property: "panelList" },
            { _ref: "application", _enum: "ordinal", _value: "targetEnum" },
        ]
    }], {});
    return (propertyName in result) ? result[propertyName] : null; 
};

batchPlay で取得できたパネルのデータには visible というパネルの表示/非表示を管理しているパラメータがあるので、これを基準に表示されているパネルだけ Store に登録しておきます。

import { unstable_batchedUpdates } from "react-dom";
import { create } from "zustand";
import { entrypoints } from "uxp";
import { getPanelList } from "../batchs/get/getApplication";
import Utils from "../utils";


const panelIdPrefix = "panelid.dynamic.uxp/my_panels/";

export const useActivePanelStore = create(
    set => ({
        panels: {},
        update: async () => {
            let manifestPanelIds = Array.from(entrypoints._panels).map(v => v.id);
            let panelIds = manifestPanelIds.map(v => `${panelIdPrefix}${v}`)
            let result = (await getPanelList())
                .filter(v => panelIds.includes(v.ID))
                .reduce((obj, data) => {
                    const id = data.ID.replace(panelIdPrefix, "");
                    obj[id] = data.visible;
                    return obj;
                }, {});
            set({ panels: result });
        }
    }),
);

const debouncedUpdate = Utils.debounce(async () => {
    await useActivePanelStore.getState().update();
}, 100);

document.addEventListener("uxpcommand", async () => {
    unstable_batchedUpdates(() => {
        debouncedUpdate();
    });
});

あとは必要な場所で Store からアクティブになってるパネルを取得することができます。

const activePanels = useActivePanelStore(state => state.panels);

まとめ

UXP Plugin の開発環境はまだ発展途上である感は否めませんが、それでも問題の回避方法を知っていくと、以前よりも開発が楽になる点が多く、移行するメリットはおおいにあると思います。 日本語のコミュニティもまだあまりなく、本記事をきっかけに少しでも多くの方が興味を持っていただき UXP での開発に参加していただけると幸いです。