Denoで「画像変換&加工」アラカルト
Deno Advent Calendar 2022の7日目の記事です!
画像のリサイズや変換面倒ですね。PhotoShopのようなレタッチツールを使う方、ImageMagickのようなCLIツールを使う方、シェルスクリプトやバッチファイルまで用意されてる方、色々いるのではないでしょうか。ちなみに個人的にはSquooshやClip Studio Paintなどでやってしまうことが多いです。
このあたり、Denoで置き換えると便利ではないだろうか、と考えました。
メリットはこういったものがありそうです。
- 高いポータビリティ
- 何ステップにも渡る複雑な変換処理をTypeScriptで綺麗に書ける
- Deno.watchFsを使った監視&自動処理
- deno compileを使ったexe化と、ドラッグアンドドロップによる簡単な変換アプリの作成
というわけで、今回はDenoでどこまで画像系のタスクを行えるのか検証してみます。
画像のリサイズを行う
まずは簡単&基本的なリサイズ処理です。
これはImageScriptというツールを使うケースが多いです。
import { Image, decode } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
const img = await decode(await Deno.readFile("./input.png"));
const small = img.resize(img.width / 2, Image.RESIZE_AUTO);
if(small !== undefined) {
await Deno.writeFile("out.jpeg", await small.encodeJPEG());
}
deno run --allow-write=. --allow-read=. --allow-net=deno.land .\main.ts
を実行すると、半分のサイズにリサイズされたJPEGファイルが生成されます。
このImageScriptですがかなり高機能で、GIFやWEBPが生成できたり、トリミングや回転、画像合成など基本的なレタッチが単体でできるようです。
ちなみにImageScriptですがwasm部分がオープンソースになっていないようですが、作者いわく、ただのRustライブラリへのバインディングだよとのことです。PureJSで書かれたライブラリも選択肢として欲しいところですね。
Webページを取得して画像化
Puppeteerでサイトを画像化して、ImageScriptでPNGに変換して出力します。
import puppeteer from "https://deno.land/x/puppeteer@16.2.0/mod.ts";
import { Image } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
const url = "https://yahoo.co.jp/";
const browser = await puppeteer.launch({
defaultViewport: { width: 1200, height: 675 },
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle2" });
const raw = await page.screenshot();
await browser.close();
if (!(raw instanceof Uint8Array)) {
console.log("Invalid Image");
Deno.exit(0);
}
const image = await Image.decode(raw);
await Deno.writeFile("out.png", await image.encode());
実行するとWebページのスクリーンショットが出力されます。
実例として、FreshのShowcaseのサムネイル生成はDenoで生成するようになっています。
HTML + CSSからPNGを生成する
Vercelが最近リリースしたSatoriというライブラリは、HTML + CSSからテキストのアウトラインも含めてSVGに変換し、resvgというライブラリを内部的に利用してラスター画像に変換します。
このSatoriをDenoから使うライブラリがありますので、それを使って、コードから画像を生成するコードを書いてみましょう。
なお、JSXを直接ファイルに書けるのでTSXファイルとして作成するのがおすすめです。
import React from "https://esm.sh/react@18.2.0";
import { ImageResponse } from "https://deno.land/x/og_edge@0.0.4/mod.ts";
const res = new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 128,
background: "#646",
color: "white"
}}
>
<img height={120} src="https://fresh.deno.dev/logo.svg" alt="" />
Hello, Deno!
</div>,
);
res.blob().then(async (blob) => {
Deno.writeFile("og.png", new Uint8Array(await blob.arrayBuffer()));
});
すると、このようなPNG画像が出力されます。
この構成の面白いところですが、deno run --watch
で実行すると、ソースコードの更新で再実行されるため、保存するたびに画像も書き換わります。プレビュー代わりに使えますね!
GIFを小さくする
ImageScriptはGIFのエンコード・デコード機能を備えています。
これを利用して、巨大なGIFファイルを縮小・フレームレートを変更してみましょう。
import { GIF, Frame } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
const orig = await Deno.stat("deno.gif")
console.log(`Original File: ${(orig.size / 1024).toFixed(0)}kB` )
const input = await Deno.readFile("deno.gif")
const gif = await GIF.decode(input)
const frames: Frame[] = []
for (let i = 0 ; i < gif.length ; i++) {
if(i % 2 == 0) continue
gif[i].scale(0.3)
gif[i].duration *= 2
frames.push(gif[i])
}
await Deno.writeFile("mini.gif", await new GIF(frames).encode())
const mini = await Deno.stat("mini.gif")
console.log(`Shrinked File: ${(mini.size / 1024).toFixed(0)}kB` )
各フレームのサイズを0.3倍し、フレーム数を1/2にしています。早送りにならないようにdurationを2倍に伸ばしています。
オリジナル版:
縮小版:
サイズは下記のようになりました。
Original File: 532kB
Shrinked File: 55kB
GIFの各フレームには配列風にアクセスできますが、mapやfilterなどは使えずややクセも感じます(下記のようなエラーが出ます)。
error: Uncaught (in promise) TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function
PSDを読み込む
PSDを扱うライブラリにpsd.jsがありましたが、こちらはDenoでは動きませんでした(中身を見たらCoffeeScriptで驚きました。まだCoffeeScript更新されてるんですね…)。
代替として見つけたのがこちらのwebtoon/psd
というライブラリです。
これ自体はブラウザでの動作を想定しているようですがもちろんDenoでも動きます。
このようなPSDデータをClip Studio Paint(以降クリスタ)で用意しました。
通常はクリスタで作成した画像は書き出しでPNGなどにするのですが、これをDenoだけで統合した画像をPNGで書き出してみましょう。
import Psd from "npm:@webtoon/psd";
import { Image } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
const result = Deno.readFileSync("example.psd");
const psdFile = Psd.parse(result.buffer);
const raw = await psdFile.composite();
const img = new Image(psdFile.width, psdFile.height);
img.bitmap = raw;
await Deno.writeFile("out.png", await img.encode());
これで、example.psdをレイヤー結合した画像がout.png
に出力されます。
psdFile.composite()で画像のビットマップデータがUint8ClampedArrayで取得されます。これをPNG形式で保存するためにImageScriptを利用します。
なぜPSDから直接レイヤー結合画像が得られるかというと、Photoshopの互換モードでPSDを保存すると、統合画像もPSD内に埋め込まれた形で出力されるようです。クリスタもこの挙動に準拠している模様です。
ただ、どの時点での問題なのか不明ですが、透過がされず白いバックグラウンドが見えています(もとより互換モードで保存される統合画像はこういうものなのかもしれません)。
また、レイヤごとに画像を出力することも可能です。
psdFile.layers.forEach(async (layer) => {
const i = new Image(layer.width, layer.height);
i.bitmap = await layer.composite();
await Deno.writeFile(layer.name + ".png", await i.encode());
});
このように出力されました。
実行ファイルを作ってドラッグアンドドロップで変換処理をする
画像ファイルをドロップするとサムネイルを作ってくれるツールを作ってみましょう。
import { Image } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
async function generateThumbnail(path: string){
if(!path.endsWith(".png")){
console.log("Invalid file type");
Deno.exit(0);
}
const result = Deno.readFileSync(path);
const img = await Image.decode(result.buffer);
img.cover(300, 300);
await Deno.writeFile(path+ ".thumbnail.png", await img.encode());
}
const args = Deno.args;
if(args.length > 0){
args.forEach(async (path) => {
await generateThumbnail(path);
});
}else{
console.log("No input");
}
これで、deno compile --allow-write=. --allow-read=. --allow-net=deno.land .\main.ts
で実行ファイルを生成し、デスクトップにコピーします。
あとは、PNGファイルをドロップすれば、ファイル名.thumbnail.pngというサムネイルを生成してくれます。複数ファイルのドロップにも対応していますよ!
deno compile
は簡単に実行ファイルが作れて、自分専用のツールを作るのに便利ですが、現時点でnpmライブラリを利用しているとdeno compile
はできません。本記事で紹介しているPSDローダのnpm:@webtoon/psd
がその制約に引っかかります。今のところバッチやショートカットを利用してスクリプトを実行する形にする必要があります。
ディレクトリ内を監視して自動処理する
Denoでは、標準APIの watchFs
でファイルやディレクトリの監視を行う事ができます。
下記は、特定のフォルダ内にファイルが作成されたとき、出力フォルダ内に色情報を反転させた画像を出力するスクリプトです。
import { Image } from "https://deno.land/x/imagescript@v1.2.14/mod.ts";
import { join, parse } from "https://deno.land/std@0.137.0/path/mod.ts";
const events = await Deno.watchFs("input", { recursive: true });
console.log("Watching `./input` directory for changes...");
for await (const event of events) {
if (event.kind === "create") {
const { dir, name, ext } = parse(event.paths[0]);
console.log(`Processing ${name}${ext}...`);
const input = event.paths[0];
const output = join(dir, "../output", `${name}.${ext}`);
try {
await applyFilter(input, output);
} catch (e) {
console.log(e);
}
}
}
async function applyFilter(input: string, output: string) {
if (!input.endsWith(".png")) {
return;
}
const result = await Deno.readFile(input);
const img = await Image.decode(result.buffer);
img.invert();
await Deno.writeFile(output, await img.encode());
}
以上までのソースコードは下記に置いてあります。
Discussion