収集し、処理可能なAirtableとFirebase


クイック週末プロジェクトWriteup.Loungeware アート、コード、音楽からの貢献で、コミュニティはWariowareスタイルゲームを開発しました.このゲームの特徴は、文字がラールドとして知られている文字の画像を提出した.



スペース
スペース・ベーク

コミュニティStudio O 2によるWariowareスタイルのコラボレーションゲーム
午前5時12分- 2021年8月04日
以前は、ラールズが提出されました.複数のプロセスを通して処理されなければならなかった不和の上に送られたPNGファイル
  • 画像を確保する200 x 200 px
  • 2色パレット(アンチエイリアシングなし)に立ち往生しているイメージを確実にしてください
  • 寄稿者名と他のメタデータをコードの配列に集める
  • スプライトのフレームにイメージをコピーし、スプライトのイメージインデックスをメタデータ配列に一致させる
  • 別々に、オンラインギャラリー/クレジットのためにウェブサイト倉庫にイメージとメタデータをコピーしてください
  • 簡単ではありませんが、プロセスは、時間がかかるとエラーがちなので、私はそれを自動化したい.そうするために、私はAirTableを使用するつもりです.そして、それは私がイメージと他のデータを提出するためにユーザーにウェブベースのフォームをつくるのを許します;とfirebase関数の両方を処理し、処理画像を格納します.

    エアテーブル
    Airtableは、スプレッドシートとデータベースの組み合わせであるオンラインサービスです.これは、APIを使用してクエリすることができますデータベースを作成することができます.また、投稿フォームを作成することができます.
    私は、larold提出物のための単純なデータベースを作成します、これはデータのグリッド・ビュー(すなわちスプレッドシート・ビュー)です.

    これを設定すると、データベースにデータを送信できる新しいパブリックフォームを作成できます.データとグリッドビューはプライベートですが、パブリックフォームは、ユーザーが新しいlaroldの投稿を投稿するために使用することができます.Google Docsをよく知っている人々は、これがGoogleフォームに非常に類似しているのを見ます

    管理者だけが見て、素敵なビューは、画像の拡大表示を示すギャラリービューです.


    AirtableへのAPIアクセス
    オートメーションは、データへのプログラムのアクセスなしで可能でありません.Airtableを選ぶための私の理由は、データにアクセスするための使いやすいAPIです.
    まず、アカウント設定でAPIキーを生成しなければなりません

    次に、PostgreSQLを使用してHTTPリクエストを介してデータを取り込みます.

    上のスクリーンショットから、データベースのレコードがレコード配列のJSON構造として出てきて、完全フィールド名がキーとして出てくることがわかりますAirtableのCDNのパブリックURLとして利用できるアップロードされたイメージで.

    画像の処理
    これらの画像のいくつかは右の寸法または右の色ではないので、我々は画像を処理するつもりです.私はImageMagick、コマンドライン画像処理ツールの長年のユーザーだった.幸いなことに、firebase関数のexecution environment 実際にImageMagickは、我々はそれを使用して画像を処理することを意味し、実際には、環境ffffpeg含まれています!私はfirebase関数を使用します.
  • 最新データをAirtableから取得する
  • データをFireStoreに同期するように、メタデータはギャラリーのウェブサイトにご利用いただけます
  • 必要に応じて画像を処理し、データをギャラリーに利用できるようにクラウドストレージに保存します
  • つのPNG画像のすべてのlarold画像を含むスプライトストリップを生成する
  • スプライトストリップとメタデータJSONを返します.ZIPファイル

  • Step 1 :最新のデータをAirtableから取得する
    簡単にするために、私は公式を使用していますAirtable npm package APIにアクセスするには時
    Airtableパッケージを使用すると、アクセスの設定は比較的簡単です.
    const functions = require("firebase-functions");
    const Airtable = require("airtable");
    
    Airtable.configure({
      endpointUrl: "https://api.airtable.com",
      apiKey: functions.config().airtable.api_key,
    });
    const base = Airtable.base(functions.config().airtable.base);
    
    async function doSync() {
      const records = await base("Larolds").select({
        view: "Grid view",
      }).all();
    }
    
    ここでは、Firebaseのを使用しているfunctions.config() コード内のハードコーディング感度の高い値を避けるために環境から秘密を取得します.一度設定します.base("Larolds").select().all(); すべてのレコードを取得します( Pageinationを処理します).結果は、繰り返されることができる記録の構造です.

    ステップ2:FireStoreと同期
    FireStoreのセットアップをスキップします私はすべてのレコードを同期させているので、残念ながら私はFirestoreコレクションからすべてのレコードをフェッチし、変更された日付をチェックし、その後変更を書き込むことが少し厄介なことをしなければなりません.FireStoreは、常にすべてのレコードを一度に更新状況に適していないので、これは厄介です.実際に、私はアクセスコストを最適化するために、すべてのデータを単一のFirestore文書に書き込むべきです.しかし、低トラフィックサイトでは、必要に応じて個々のドキュメントを使用し、後で更新します.
    const records = await base("Larolds").select({
        view: "Grid view",
      }).all();
    
      functions.logger.info("Got larolds from airtable", {count: records.length});
    
      const existingDocuments = await laroldStore.listDocuments();
      const existingData = Object.fromEntries(existingDocuments.map((doc) => [doc.id, doc.data]));
    
      // Update image
      const laroldData = await Promise.all(records
          .filter((record) => (record.get("Image file").length > 0 && record.get("Confirmed for use") == "Yes"))
          .map(async (record, idx) => {
            const image = record.get("Image file")[0];
            const id = image.id; // use the image unique ID as id
            const modified = record.get("Last modified");
    
            // Check if updated
            let doc;
            if (!existingData[id] || existingData[id].modified != modified) {
              const imageUrl = image.url;
              const {warnings, destination} = await processImage(imageUrl, image.filename, id);
              doc = {
                id: id,
                name: record.get("Larold name"),
                attribution: record.get("Attribution name"),
                submitter: record.get("Submitter"),
                imageUrl,
                modified,
                idx: idx+1,
                warnings,
                destination,
              };
              await laroldStore.doc(id).set(doc);
            } else {
              doc = existingData[id];
            }
    
            return doc;
          }));
      const updatedIds = laroldData.map((doc) => doc.id);
      functions.logger.info("Updated larolds in store", {updatedIds});
    
      // Remove old ones
      const deleteDocs = existingDocuments.filter((doc) => !updatedIds.includes(doc.id));
      const deletedIds = deleteDocs.map((doc) => doc.id);
      await Promise.all(deleteDocs.map((doc) => doc.delete()));
    
    スクリプトのこの大きな塊は、Airtableからすべてのレコードを取り出し、Firestoreから、それらを反復し、どのドキュメントが更新(および更新)を必要とするかを理解します.(それらは更新されます).
    行があることに注意してくださいconst {warnings, destination} = await processImage(imageUrl, image.filename, id); 次のステップでカバーされているコードで.このコードがこの中にある理由if チェックは、すでに処理されたイメージを処理しなければならないことです.
    結果はFireBaseの優れたローカルエミュレータで見ることができます.


    ステップ3プロセスイメージ
    画像を処理すると、ImageMagickを使って、この詳細はofficial Firebase tutorial . 残念なことに、ImageMagick自体は少しの時代遅れの、そして率直に非常に難しい指示に従うことから始めることを学ぶのが少し難しいです.幸いにもImageMagickの私の親しみやすさは、ソースコードの周りのいくつかの掘削と組み合わせると、私はこの1つを把握するのに役立ちました.
    画像処理はさらに3つのステップに分けられます.
  • Laroldイメージが使用しなければならない限られた2色パレットにどんな「不正な」色を再マップするのに必要であるパレットイメージを生成してください.
  • ので、我々は彼らのイメージが間違っている、彼らは更新したいアーティストを警告することができますので、警告を生成することができます画像の色の数をカウントします
  • リサイズし、イメージを再マップし、バケットにアップロードします.

  • ステップ3.0パレットイメージを生成する
    私たちはこれをする必要があります、そして、私は実際にこれをしようとしているレース危険に遭遇しました、2つの反復が同時にパレットを生成しようとするので、私はそれをしようとします)ので、私はmutex(async mutex npmパッケージを通して)にそれを包まなければなりませんでした
    async function drawPalette() {
      const palettePath = "/tmp/palette.png";
    
      await paletteMutex.runExclusive(async () => {
        try {
          await fs.access(palettePath);
        } catch (error) {
          await new Promise((resolve, reject) => {
            gm(2, 1, "#1A1721FF")
                .fill("#FFC89C")
                .drawPoint(1, 0)
                .write(palettePath, (err, stdout) => {
                  if (err) {
                    reject(err);
                  } else {
                    functions.logger.info("Created palette file", {palettePath, stdout});
                    resolve(stdout);
                  }
                });
          });
        }
      });
    
      return palettePath;
    }
    
    この関数は、GM/ImageMagickにラガーの2色の色を含む2 x 1ピクセルのPNGファイルを描画します.

    ステップ3.2色数をカウントする
    GM/imagemagickのidentify() 関数はすぐにイメージで使用される実際の色の色を読み、それを返す
    async function countColors(file) {
      return new Promise((resolve, reject) => {
        gm(file).identify("%k", (err, colors) => {
          if (err) {
            reject(err);
          } else {
            resolve(colors);
          }
        });
      });
    }
    

    ステップ3.3
    次の関数は、これらの作品を一緒にプルし、Axiosを使用して、URLから画像を取得し、一時ファイルに書き込み、リサイズと再マップの変換を行うと、バケットストレージにアップロードし、任意の警告を生成
    async function processImage(url, originalFilename, id) {
      const tempFileIn = `/tmp/${id}_${originalFilename}`;
      const tempFileOut = `/tmp/${id}.png`;
    
      // get file
      const res = await axios.get(url, {responseType: "arraybuffer"});
      await fs.writeFile(tempFileIn, res.data);
      functions.logger.info("Got file", {url, tempFileIn});
    
      // check colors
      const colors = await countColors(tempFileIn);
    
      // make palette
      const palettePath = await drawPalette();
    
      // do conversion
      await new Promise((resolve, reject) => {
        gm(tempFileIn)
            .resize(200, 200, ">")
            .in("-remap", palettePath)
            .write(tempFileOut, (err, stdout) => {
              if (err) {
                reject(err);
              } else {
                functions.logger.info("Processed image", {tempFileOut, stdout});
                resolve(stdout);
              }
            },
            );
      });
    
      // upload
      const destination = `larolds/${id}.png`;
      await bucket.upload(tempFileOut, {destination});
    
      // assemble warnings
      const warnings = [];
      if (colors != 2) {
        warnings.push(`Incorrect number of colors (${colors}) expected 2`);
      }
    
      await fs.unlink(tempFileIn);
      // await fs.unlink(tempFileOut); // might use this for cache
    
      functions.logger.info("Uploaded image", {destination, warnings});
      return {
        warnings,
        destination,
      };
    }
    
    厳密に言えば、これはより多くの機能に分解されるべきです.

    ステップ4:スプライトストリップを生成する
    最後に、すべての画像が処理され、安全にバケットにアップロードされると、我々はスプライトストリップを生成することができます.
    このコードは、ステップ2によって作成されたデータ構造を取り、どちらかをバンドルストレージからイメージをプルダウンしたり、便利にTMPフォルダに残っている処理された出力ファイルを見つける
    async function makeComposite(laroldData) {
      // ensure images are downloaded
      const localPaths = await Promise.all(laroldData.map(async (doc) => {
        const localPath = `/tmp/${doc.id}.png`;
        try {
          await fs.access(localPath);
        } catch (error) {
          functions.logger.info("Downloading image", {destination: doc.destination});
          await bucket.file(doc.destination).download({destination: localPath});
        }
        return localPath;
      }));
    
      // montage
      const buffer = new Promise((resolve, reject) => {
        localPaths.slice(0, -1)
            .reduce((chain, localPath) => chain.montage(localPath), gm(localPaths[localPaths.length -1]))
            .geometry(200, 200)
            .in("-tile", "x1")
            .toBuffer("PNG", (err, buffer) => {
              if (err) {
                reject(err);
              } else {
                resolve(buffer);
              }
            },
            );
      });
    
      // cleanup
      await Promise.all(localPaths.map((localPath) => fs.unlink(localPath)));
    
      return buffer;
    }
    
    ここで行われる楽しいことはスライスの使用であり、イメージを一緒にモンタージュに必要なメソッドチェーンをアセンブルするために削減されます.通常、コードは3つのイメージモンタージュのためのものです.gm(image2).montage(image0).montage(image1) , そしていくつかの理由でそれはイメージの引数を置くgm() 右側に.したがって、任意の長さのチェーンを扱うためには、値をループすることができます.
    let chain = gm(localPaths[localPaths.length -1]);
    for (let i = 0; i < localPaths.length-1; i++) {
      chain = chain.montage(localPaths[i]);
    }
    
    これは以下のようになります.
    localPaths.slice(0, -1).reduce((chain, localPath) => chain.montage(localPath), gm(localPaths[localPaths.length -1]))
    

    ステップ5
    zipファイルの取り扱いjszip npm library , この関数は、ファイアウォール関数が表すNodeBuffer内のZIPを非同期で返すことができます.JSランタイムは直接戻ることができます.
      // generate composite and zip
      const zip = new JSZip();
      zip.file("larolds.json", JSON.stringify(laroldData, null, 2));
    
      if (laroldData.length > 0) {
        const compositeBuffer = await makeComposite(laroldData);
        zip.file(`larolds_strip${laroldData.length}.png`, compositeBuffer, {binary: true});
      }
    
      functions.logger.info("Done sync", {laroldData});
      return zip.generateAsync({type: "nodebuffer"});
    
    そして完了!私は意図的に完全なソースファイルを全く含んでいません、しかし、うまくいけば、上記のコード例はAirtableからイメージを処理するためにFireBase機能の中にGM/ImageMagickを使用したい誰かに便利です.私は、Firebase機能がセットアップされるデフォルト256 MBより少しRAMを必要とする実行を見つけました.そして、それは現在512 MBのRAMで幸せに走り回っています、しかし、より大きなイメージを取り扱うためにぶつかる必要があるかもしれません.
    現在の使用法は、必要に応じてzipファイルを単にダウンロードすることですが、将来の繰り返しでは、このzipファイルをci/cdにダウンロードし、main ブランチ、これをさらに自動化する.