2017-03-30 00:55:36

ExtendScript闲谈

ExtendScript是Adobe基于1999年的ecma262标准(即通常所说的es3)实现的一门类似javascript的脚本语言,被用来操作Adobe系列十几款软件。

读者先不要鄙视它古老的年代,这里可以先说一件事:ExtendScript的默认脚本文件后缀为jsx。是的,你没看错,就是react一直用的那个jsx,并且ExtendScript的脚本引擎直接支持jsx语法

function makeXML (first, last) {
  return <person first={first} last={last}>{first + " " + last}</person>;
}

makeXML('Jane', 'Doe').toXMLString()  // return <person first="Jane" last="Doe">Jane Doe</person>

目前还能下载得到的支持ExtendScript算是Adobe CS3这个老古董系列了,于2007年7月发布,比react第一次commit早了整整6年。

另外,既然jsx都出现,mvvm的双向绑定也该出现了,没错,ExtendScript内置类似于Object.defineProperty的api。

我从高三开始就在使用AE,并且在大二时有一半时间集中在玩视频后期,当时用ExtendScript玩了AE和PS大概半年时间。ExtendScript在语法层面上有很多优秀的地方,这也是我在大三毫无压力地开写react和react native的原因。

即使长时间使用es6/es7标准以及babel和webpack的工作流,ExtendScript仍然能够展现出吸引我的地方。本文就聊一聊ExtendScript中一些相当有意思的一些东西

jsxbin逆向工程

jsxbin格式,是ExtendScript的IDE提供的一个加密格式,它将源码加密导出为不可读但可以被Adobe软件运行的字符串。基于这个加密格式,国外建立了一个叫aescripts的组织,专门对用户出售脚本。

但是,ExtendScript其实更类似于node后端而不是前端,因为jsxbin加密格式是稳定的,并且可以通过IDE重复导出来得到无限的输入和有限的输出,因此逆向工程是绝对可行的。

github上搜索jsxbin即可发现一个c#写的逆向jsxbin的工程。但是,这个仓库最新release才1.6,实际上它不是原始仓库,原始仓库已经被人DCMA举报后被github干掉了,最新的release实际上是1.9,主要修复了图片解码和语句顺序错误的问题,当然,这两个问题都是我提的issue。

jsxbin已经被破解,这也是我写的总计几万行的脚本都直接放开源码的原因。

操作符重载

Adobe使用的JavaScript严格来说并不是JavaScript,它只是ECMA262的另一种实现.

一个典型的例子是,基于ES3的JSX支持操作符重载,而JavaScript的操作符重载已经被ES4标记为危险并废除,最新的实现还被扔在ES7那呢.

我在这里封装了一个操作符重载的函数. 注意,此函数第二个值rev是指是否是reversed.

注册操作符重载如下

var cout = $.global.cout = new OperatorOverload(function(operand, rev) {
  if (!rev) { $.writeln(operand) } else { alert(operand) }
}, '<<')

cout<<"这是输出到控制台的字符串";
"这是弹出窗口中的字符串"<<cout;

这样,就实现了一个c++风格的输出方法。

内置的xml处理能力

ExtendScript是ES3环境,默认没有JSON,因此Adobe给了它内置的XML处理能力。

它带来了很多有意思的东西

  • jsx的命名
  • 各种神奇的语法
    • 类react jsx的定义语法
      • return <person first={first} last={last}>{first + " " + last}</person>;
    • 双点操作符: XML..person
      • 上面的代码是搜索xml中标签名为person的所有xml
    • @获取属性: XML.@name
      • 用来获得XML的属性
    • 属性匹配语法糖: XML.(@name = 'first')
      • 这个语法糖碉堡了,ES7有一个类似的,obj.(tag ? undefined ),超甜好嘛
  • 出乎意料的性能
    • 匹配标签的搜索性能远快于字符串匹配
      • 前者是后者速度的十倍以上,这与使用immutable后的特征非常像

nested tenary bug

ExtendScript的解析引擎有一个bug,即嵌套三元操作符会被认为无效,就像下面这种

var result = isA ? isB ? 'B' : 'A' : 'C'

这导致babel-polyfill无法在ExtendScript使用,需要自行写一个babel插件,来给每一个三元操作符添加小括号

scriptUI

scriptUI是ExtendScript的界面工具合集,用来创建跨Adobe软件的脚本界面。

scriptUI有非常非常多的bug,总计五六十个版本的软件,兼容性可以参考前端兼容IE6的感觉。

早期的scriptUI允许播放flash,但是现在不行了。2015年尾时为了用户体验,我的朋友阿木帮我写了个网格视图控件,地址在这里,我在这个控件的基础上修改了一下异步的地方,让它支持了动画预览,这个功能也是我的开源脚本的杀手锏之一。

IDE

ExtendScript的IDE名叫ESTK,这是我唯一能明显看出ExtendScript历史的厚重的地方,UI丑,调试渣,控制台卡爆,缩进4改不了,mac上字体都是虚的,行尾符槽点无数。

截图如下,可以看到有多渣

移植前端工作流

这也是这次维护里我的重心,ExtendScript的开发没有热重载,并且导出jsxbin后没有sourcemap因此报错定位全废了,在2017年这个时间真是一股浊流,不用前端工作流我真的不愿意再写这些代码了。流程如下

前置

  • node.js > 4.0
  • yarn.js
    • npm被设计为不稳定的的,使用yarn是为了让每次构建出来的文件在源码不变的情况下不会有任何变化

安装依赖

yarn add after-effects babel-core babel-eslint babel-loader babel-preset-es2015 babel-preset-stage-2 cross-env eslint eslint-config-standard eslint-plugin-import eslint-plugin-node eslint-plugin-promise eslint-plugin-standard osascript webpack

除了webpack,eslint也得用上。osascript是为了直接在mac上用命令行跑脚本,after-effects是为了获得ae的具体路径。

给package.json添加script:

  "scripts": {
    "dev": "webpack --config ./buildack.config.js --watch --progress --hide-modules",
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "cross-env NODE_ENV=production webpack --config ./buildack.config.js --progress --hide-modules",
    "lint": "eslint . --quiet",
    "fix": "eslint . --fix --quiet"
  },

配置eslint

添加.eslintignore, 内容为

dist

添加.eslintrc.js, 内容为

module.exports = {
  root: true,
  parser: 'babel-eslint',
  parserOptions: {
    sourceType: 'module'
  },
  // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
  extends: 'standard',
  globals: {
    "$": true,
    "Panel": true,
    "Folder": true,
    "GridView": true,
    "Window": true,
    "app": true,
    "File": true,
    "prompt": true,
    "alert": true,
    "XML": true,
    "confirm": true,
    "CompItem": true,
    "ScriptUI": true,
    "Socket": true,
    "ImportOptions": true,
    "ImportAsType": true,
    "cout": true,
    "clearOutput": true,
    "writeLn": true,
    "PropertyType": true,
    "Language": true,
    "system": true,
    "TextLayer": true,
    "LightLayer": true,
    "ShapeLayer": true,
    "AVLayer": true,
    "SolidSource": true,
    "FileSource": true,
    "CameraLayer": true,
    "Shape": true,
    "KeyframeEase": true,
    "PropertyValueType": true,
    "MarkerValue": true,
    "PurgeTarget": true,
    "memoryGlobal": true
  },
  // add your custom rules here
  'rules': {
    // allow paren-less arrow functions
    'arrow-parens': 0,
    // allow async-await
    'generator-star-spacing': 0,
    'space-before-function-paren': ['error', 'never'],
    // allow debugger during development
    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0
  }
}

其中global是声明哪些是全局变量,读者用到什么就加什么就行了。

配置webpack

新建四个文件:

buildack.config.js

const webpack = require('webpack')
const path = require('path')
const packages = require('../package.json')
const os = require('os')
const osascript = require('osascript').eval
const WebpackShellPlugin = require('.ackShellPlugin')
const WebpackWrapPlugin = require('.ackWrapPlugin')
const plugins = [
  new webpack.DefinePlugin({
    'process.env.VERSION': JSON.stringify(packages.version)
  }),
  new WebpackWrapPlugin({
    prefix: '/****/ (function(memoryGlobal) {',
    suffix: '/****/ })(this)'
  }),
  new webpack.BannerPlugin({
    banner: `  ${packages.name} ${packages.version}

  ${packages.description}

  repository: ${packages.homepage.replace('#readme', '')}
  issues: ${packages.bugs.url}`
  })
]

const isDev = process.env.NODE_ENV !== 'production'
const targetScript = path.join(__dirname, '../dist/Sp_memory.jsx')

if (isDev) {
  const ae = require('after-effects')
  const isMac = os.platform() === 'darwin'
  if (isMac) {
    const aeFolderName = path.basename(path.join(ae.scriptsDir, '../'))
    const appleScriptContent = `tell application "${aeFolderName}"
  DoScriptFile "${targetScript}"
end tell`
    plugins.push(
      new WebpackShellPlugin({
        onBuildEnd: function() {
          osascript(appleScriptContent, {
            type: 'AppleScript'
          }, (err, data) => {
            if (err) console.error(err)
          })
        }
      })
    )
  } else {
    const afterfx = path.join(ae.scriptsDir, '../afterfx.exe')
    const shell = `"${afterfx}" -r ${targetScript}`

    plugins.push(
      new WebpackShellPlugin({
        onBuildEnd: [shell]
      })
    )
  }
}

module.exports = {
  entry: {
    app: path.join(__dirname, '../index.js')
  },
  output: {
    path: path.join(__dirname, '../dist'),
    filename: 'Sp_memory.jsx'
  },
  resolve: {
    extensions: ['.js'],
    alias: {
      'src': path.resolve('__dirname', '../src'),
      'lib': path.resolve(__dirname, '../lib'),
      'helper': path.resolve(__dirname, '../lib/HelperScripts')
    }
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      }
    ]
  },
  plugins: plugins
}

这个配置的入口文件是根目录的index.js,构建文件为build/Sp_memory.jsx,自己更改Sp_memory名字即可。

buildackShellPlugin.js

'use strict'

const exec = require('child_process').exec

function puts(error, stdout, stderr) {
  if (error) {}
  if (stdout) console.log(stdout)
}

function WebpackShellPlugin(options) {
  const defaultOptions = {
    onBuildEnd: []
  }

  this.options = Object.assign(defaultOptions, options)
}

WebpackShellPlugin.prototype.apply = function(compiler) {
  const options = this.options

  compiler.plugin('emit', (compilation, callback) => {
    if (options.onBuildEnd.length) {
      options.onBuildEnd.forEach(script => exec(script, puts))
    } else if (typeof options.onBuildEnd === 'function') {
      options.onBuildEnd()
    }
    callback()
  })
}

module.exports = WebpackShellPlugin

这个插件主要用来在构建完毕后运行脚本

buildackWrapPlugin.js

'use strict'

const ConcatSource = require('webpack-sources').ConcatSource

function WebpackWrapPlugin(options) {
  const defaultOptions = {}

  this.options = Object.assign(defaultOptions, options)
}

WebpackWrapPlugin.prototype.apply = function(compiler) {
  const options = this.options
  const prefix = options.prefix
  const suffix = options.suffix

  compiler.plugin('compilation', (compilation) => {
    compilation.plugin('optimize-chunk-assets', (chunks, callback) => {
      chunks.forEach((chunk) => {
        if (options.entryOnly && !chunk.isInitial()) return

        chunk.files.forEach((file) => {
          return compilation.assets[file] = new ConcatSource(
                   prefix, '\n', compilation.assets[file], '\n', suffix
                 )
        })
      })
      callback()
    })
  })
}

module.exports = WebpackWrapPlugin

这个插件是为了将全局的this通过函数传参变为一个变量名,好让webpack打包后的模块内仍然能访问到全局this。

这是因为,直接赋值var self = this是会报Panel() is not function的错的,非常非常迷,这摆明了ESTK自己在做什么见不得人的事情。全局this在Panel模式打开时指代Panel对象,如果不使用这个插件,会导致Panel窗口加载失败。

.babelrc

{
  "presets": ["es2015", "stage-2"],
  "comments": false
}

结果

现在,yarn run dev就可以实现热重载,即修改文件时webpack自动构建并在AE中重新运行,yarn run lint则可以使用lint。

在开发状态下,脚本窗口会重复出现,最好注册一个全局函数,用来关闭之前的窗口。

$.global.callbackBeforeWebpackBuild && $.global.callbackBeforeWebpackBuild()
if (!(global instanceof Panel)) {
  $.global.callbackBeforeWebpackBuild = function() {
    win.close()
  }
}

memoryGlobal代表全局this,直接拿来用就可以了。

内存快照

$.list()返回内存快照字符串,一般用来看内存泄漏,也可以用来查看全局变量究竟有哪些,一般手段是根本不可能拿到全局不可枚举变量的,甚至这个$.list api都是隐藏的。

具体的代码在这:ScanRefs

运行后界面如下

这个功能相当强大,简直不像是1999年的东西,好多JS引擎都没这么详细的引用快照呢

异步

ExtendScript是全部阻塞的,只有非常少的回调。

但是,在AE的DOM中存在一个非常好的api,app.scheduleTask(stringToCall, time, isLoop),即js中的setInterval和setTimeout的合体。这个api也是预览动画的实现基础之一

mvvm双向绑定

Object.watch支持监听对象的属性变更,但只支持第一层,不过一个递归监听绑定也没啥难度,几行而已。

scriptUI这个视图层在ExtendScript的地位比浏览器在js中的地位低多了,毕竟ExtendScript主要功能在DOM层面,视图层根本不需要很大的变更,更别说组件系统了。

我只用它来监听布尔值,将布尔值自动存储在存储介质中。不过配合xml和双向绑定,ExtendScript确实已经具备了ES5浏览器中mvvm框架的实现基础了。

HTML Panel

上面刚刚说了,scriptUI视图层的地位其实是很低的,因此adobe在CC版本之后逐渐给各个软件增加了HTML Panel。

HTML Panel是视图层为浏览器的界面,它通过回调来与DOM沟通,优点是具有所有浏览器前端的优点,例如可以使用canvas绘图以及存储图片的API,以及可以更进一步的沟通node.js,来进行I/O较频繁的操作。

目前我所看到的比较成功的应用是processing for ae,除此之外真的没发现几个好用的。优点确实很多,但是有一些鸡肋,毕竟ExtendScript主要用来操作DOM。另外,它的API变更非常大,文档又很难找,Adobe更一个版本挂一个版本,对开发者太不友好了。

结语

本文更多是总结性的,低层级的ExtendScript描述可以看博主之前的一篇文章

ExtendScript相比于Javascript绝对是非主流平台,我所见过的最大的系统也就是InDesign中一个配合HTML Panel来对排版做自动化测试的系统而已。作为二维软件的操作语言,ExtendScript可以说基本满足了需求,毕竟不是每一个脚本都有接近一万行代码的memory那么大。

当然,不得不提的一点是,各软件中DOM的性能非常低,对程序员来说简直低到了不可接受的层次。毕竟是1999年到现在从来没更新过的脚本。比如说,在AE CC2015的更新中,实时预览特性导致异步的存在感猛然翻了几十倍,这种情况下Adobe居然还没有更新脚本引擎来支持类似node的东西,真的只能说没竞争的真没好事。

本文链接:https://smallpath.me/post/extend-script-the-good-part

-- EOF --