プログラミング
プログラミング
  • 2024/09/19
  • 2024/09/23

DJプレイリストから動画制作用のアセットを生成するツールを作ってみた

DJ MIXプレイを録音して、動画として残したいことがあります。今回は、プレイした楽曲のメタデータから動画制作用のSVGアセットを出力するツールをSolidJSで制作してみました。

ツールの紹介


概要

ブラウザのみで動作し、楽曲のメタデータを含むCSVとアルバムやシングルのカバー画像(JPEG)の組からSVGを生成します。

CSVのフォーマット
カンマ区切りです。各カラムはFLACファイルが含むメタデータを参考に定義してあり、以下のヘッダーを含んでいます。

FILENAME,ARTIST,TITLE,ALBUM,GENRE,TRACKNUMBER,DATE,ALBUMARTIST,COMPOSER,FLACPATH

このうち、画像生成に利用しているのはARTIST, TITLE, ALBUM, DATEの4つですが、ロードするCSVにはすべてのカラムを欠損のないように含める必要があります。セルは空白でも構いません。

プレビュー

スクリーンショット2024-09-16224713.png
ドラッグ & ドロップのUI

アプローチと技術選定


プレイした楽曲のメタデータ(タイトル、アーティスト名、アルバム名など)を格納したCSVの列とカバー画像の組から生成することを考えました。

楽曲をFLACやALACのような形式で管理している場合、ファイルにメタデータが付属しています。したがって、楽曲ファイル(FLACを利用しました)そのものからデータを取り出すインタフェースまでTypeScriptで構築することを試みましたが、面倒そうだったので 諦めました。

FLACファイルからデータを取り出す処理はシェルスクリプトで実装しています(metaflac という FLAC公式のコマンドツールを使うことでFLACファイルの中身をCLIで読み出すことができます)。

UIライブラリ

Vite を利用することとし、UI構築には好奇心から SolidJS を使ってみました。

参考記事

SolidJSが使いやすい | Zenn
次のプロジェクトSolidJSで作りませんか? | Qiita

SolidJSはReactによく似ていますが、小規模なアプリケーションであればよりシンプルに記述できると感じます。特にHooksまわりは、useStateSolidJSでは createSignal)が追加ライブラリを必要とせずにグローバルステートを宣言できるなど、使いやすい部分が多い気がします。

import { createSignal } from "solid-js";

export const [state, setState] = createSignal();
// Reactとは異なり、ステートはメソッドとして呼び出す: state()

画像生成

Vercel が開発している satori を使用し、SVGをクライアントサイドで生成できるようにしました。

実装


ドラッグ & ドロップを行うUI

CSVをドロップする部分のみ書きます。csv() を外部ファイルにステートとして保存します。

import { createSignal } from "solid-js";

export const [csv, setCsv] = createSignal("");
export const [isDroped, setIsDroped] = createSignal(false);

ドロップゾーンの要素に onDrop={(e) => drop(e)} としてイベントハンドラを定義します。イベントハンドラの内容は以下です:

const drop = async (e: DragEvent) => {
  e.preventDefault();
  if (e.dataTransfer && !isDroped()) {
    setIsDroped(true);
    if (e.dataTransfer.files.length !== 1) {
      return;
    }
    const file = e.dataTransfer.files[0];
    const ext = file.name.split(".").pop();
    if (ext !== "csv") {
      return;
    }
    const text = await getTextFromFile(file); // FileReader API
    setCsv(text);
  }
};

isDroped() はステートであり、ファイルがドロップされた後にドロップゾーンをロックする役割を果たします。
また、getTextFromFile メソッドは FileReader API を使用してCSVを string として読みだしています。

const getTextFromFile = (file: File, encoding: string = "utf-8") => {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      resolve(reader.result as string);
    };
    reader.onerror = (error) => {
      reject(error);
    };
    reader.readAsText(file, encoding);
    });
};

CSVのParse

JSでCSVを取り扱うベストプラクティスはこれといったものがなさそうです。安直ですが、行を改行文字で、列をコンマで区切って取り出したデータをオブジェクト配列にマッピングすることでパースします。

// キーとその型を定義
const cols = {
  ARTIST: "",
  TITLE: "",
  ALBUM: "",
}
type Cols = typeof cols;

export const parser = (csv: string) => {
  const lines = text.split("\n");
  const headers = Object.fromEntries(lines[0].split(",").map((h) => [h, h]))
  const entries: Cols[] = [];

  // 渡されたCSVのヘッダーが定義されたキーと合致するか検証
  const isValid = Object.keys(cols).some((key) => {
    return key in headers;
  });
  if (!isValid) return [];

  const entryLines = lines.slice(1);
  for (let i = 0; i < entryLines.length; i++) {
    if (entryLines[i] === "") continue;
    const entry = Object.fromEntries(entryLines[i].split(",").map((col, idx) => [Object.keys(cols)[idx], col])) as Cols;
    entries.push(entry);
  };
  return [headers, ...entries] as Cols[];
}

CSVからプレビュー用のテーブルを作る

parser() の返り値の型は {.....}[] ですが、JSXに展開する場合は2次元配列 [.....][] のほうがおそらく扱いやすいです。そのための変換を行います(二度手間感はぬぐえませんが) 。

import { createMemo } from "solid-js";

const tableData = createMemo(() => {
  return parser(csv()).map((entry) => {
    return Object.values(entry);
  });
});

createMemo はReactの useRef、Vueの computed に相当するフックのようです。すなわち、コールバックに含まれるステートの値が変更された場合にのみ再計算を行います。

ステートの値に関わらず、更新があった場合に毎回再計算を行うのであれば、SolidJSでは単にメソッドを書くだけでよいようです。

const tableData = () => {
  return parser(csv()).map((entry) => {
    return Object.values(entry);
  });
}

<table> 要素に展開します。

<table>
  <thead>
    {table().slice(0, 1).map((row) => (
      <tr>
        {row.map((val) => (
          <th>{val}</th>
        ))}
      </tr>
    ))}
  </thead>
 <tbody>
    {table().slice(1).map((row) => (
      <tr>
        {row.map((val) => (
          <td>{val}</th>
        ))}
      </tr>
    ))}
  </tbody>
</table>

satoriによる画像生成

今回はクライアントサイドで処理してしまいます。UIはJSXで実装していますが、satoriでもJSXを用いようとしてうまくいかなかったので、DOMはオブジェクトで記述しています。

export const generateSvg = async (entry) => {
  // fonts
  const noto400 = await fetch("/fonts/NotoSansJP-Regular.ttf").then((resp) =>
    resp.arrayBuffer()
  );
  const svg = await satori(
    // DOM
    {
      type: "div",
      props: {
        style: {
          fontWeight: 700,
          fontSize: "5rem",
          letterSpacing: "2px",
        },
        children: entry.TITLE,
      },
    },
    // setting
    {
      width: 1440,
      height: 240,
      fonts: [
        {
          name: "Noto Sans JP",
          data: noto400,
          weight: 400,
	  style: "normal",
        },		
      ],
    }
  );
  return svg;
}

フォントは /public へ配置し、Fetch APIで読み込んでいます。上記のメソッドを実行するボタンも作っておきます。

// 外部に実装しておきます: const [svgs, setSvgs] = createSignal([]);
export default function GenerateBtn() {
  const getSvgs = async (entries) => {
    const processor = async (entries) => {
      return await Promise.all(entries.map(async (entry, idx) => {
        return { svg: await generateSvg(entry), idx }
      }));
    }
  }
  const svgs = await processor(entries);
  setSvgs(svgs);

  return (
    <button type="button" onClick={() => getSvgs(parser(csv()))}>生成</button>
  );
}

画像プレビューとダウンロード

せっかくなのでモーダルで実装してみます。調べてみると、SolidJSにはモーダルを手軽に実装するためのAPIが提供されています。

import { Portal } from "solid-js/web";
import { Show } from "solid-js";

export default function Modal() {
  return (
    <Portal>
      <Show when={svgs().length > 0)}>
        <ul>
	  {banners().map((banner, idx) => (
	    <li id={`svg-${idx + 1}`} innerHTML={banner}></li>
	  ))}
	</ul>
      </Show>
    </Portal>
  );
}

このようにすることで svgs() に要素が格納されているときのみモーダルが表示されます。最後にSVGをダウンロードするボタンを作ります。

// 外部に実装しておきます: const [svgs, setSvgs] = createSignal([]);
export default function DownloadBtn() {
  const onDownload = (entries) => {
    const processor = async (node: ChildNode, fileName: string) => {
      const text = new XMLSerializer().serializeToString(node);
      const blob = new Blob([svgText], { type: "image/svg+xml" });
      const url = URL.createObjectURL(blob);

      const a = document.createElement("a");
      a.href = svgUrl;
      a.download = fileName;

      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);

      URL.revokeObjectURL(url);
    }
    svgs().forEach((_, idx) => {
      const svgId = document.getElementById(`svg-${idx + 1}`)!.firstChild!;
      processor(svgId, `picture-${idx + 1}.svg`);
    });
  }
  return (
     <button type="button" onClick={onDownload}>ダウンロード</button>
  );
}

関連記事


    記事がありません

Shota Inoue
Shota Inoue

大学生 | 化学・Webプログラミング・統計学など