NodeCGで配信画面を作ってみよう（assetsを使おう編）

## はじめに
この記事はただカッコいいという理由だけでNodeCGを使った配信レイアウトを作ってみた備忘録の第3弾です。
今回はNodeCGの機能の一つ「assets」を使用して前回作成した配信画面の背景を変更できる機能を追加していきます。

前回の記事は[こちら](https://qiita.com/nanana08/items/35015b4ac6fe1f4b0ee6)

https://qiita.com/nanana08/items/35015b4ac6fe1f4b0ee6

## 使用するプロジェクト  
今回使用するBundle（temp-bundle）は基本的に前回の記事で使用したものと同じものになります。
詳細は[前回の記事](https://qiita.com/nanana08/items/35015b4ac6fe1f4b0ee6)を参照ください。


## assetsとは
assetsとはユーザがダッシュボードから各種ファイルをアップロードしてBundle内で扱えるようにする仕組みです。

自前でファイルアップロード処理を実装する必要がないうえに、アップロードしたファイルはNodeCGが自動的にBundleのReplicantへ追加してくれるため、Bundleからのアクセスも行いやすいものとなっています。

### assetsの設定
使用するassetsの設定もその他Bundleの設定と同様、Bundle配下のpackage.jsonに記述します。

```package.json
{
  "name": "temp-bundle",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "cd src/dashboard && vite build && cd ../graphics && vite build && cd ../extension && vite build",
    "dev": "vite"
  },
  "nodecg": {
    "compatibleRange": "*",
    // ↓assetsの設定
    "assetCategories": [
      {
        "name": "background",
        "title": "背景",
        "allowedTypes": [
          "jpg",
          "jpeg",
          "gif",
          "png",
          "mp4"
        ]
      }
    ],
    // ↑ ここまで
    "dashboardPanels": [
      {
        "name": "layout",
        "title": "レイアウト",
        "width": 5,
        "height": 10,
        "file": "layout/index.html",
        "workspace": "レイアウト"
      }
    ],
    "graphics": [
      {
        "file": "layout/index.html",
        "width": "1920",
        "height": "1080"
      }
    ]
  },
  // 以下略
}
```

見たままですが各項目の簡単な説明です。
| 項目 | 説明 |
| ---- | ---- |
| name | assetsに使用する名前空間。<br/>bundle内で一意である必要がある。 |
| title | assetsのタイトル。好きに設定してOK |
| allowedTypes | アップロードするファイルの拡張子。<br/>配列形式で指定する。 |

ここまで設定してNodeCGを起動し、ダッシュボードにアクセスするとassetsにファイルをアップロードすることが出来るようになります。

![NodeCG01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125818/107990c4-e645-b7e0-fac9-141f5c20b7dd.jpeg)

ファイルを追加するとこんな感じ
![NodeCG02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125818/7eb388d4-1beb-1912-a520-b58f1574a540.jpeg)


:::note
assetsにアップロードしたファイルはassets/{Bundle名}/{assets名}/フォルダ配下に保存されるため、Replicantと同様NodeCGを再起動しても保持されます。
:::

## ソースコード

### Replicantの修正

さて、Bundleに対してファイルをアップロードできるように設定できたので
dashboardやgraphicsからファイルにアクセスできるようにしていきましょう。

前述のとおり、package.jsonに設定した各assetsは
NodeCGが自動的にReplicantを作成してくれるため、Replicantとしてアクセスすることが可能です。
今回使用しているBundleではBundleで用いるReplicantの型定義を行っているため、assetsをTypeScirptで扱うための型定義を追加していきます。

```src/replicant.ts
// assetsの型定義
export type NodecgAssets = {
    "base": string,
    "namespace": string,
    "category": string,
    "ext": string,
    "name": string,
    "sum": string,
    "url": string
}

export type ReplicantMap = {
    "telop": string,
    "background": NodecgAssets | undefined,
    // assetsは「assets:{name}」プロパティとしてReplicantに追加される。
    "assets:background": NodecgAssets[]
}

export const replicantDefaultValues: ReplicantMap = {
    "telop": "sample-telop",
    "background": undefined,
    "assets:background": []
}
```

### ソースコードを弄る
assets周りの設定が完了したので後はひたすらdashboardとgraphicsを弄るのみです。

#### dashboard

```src/dashboard/layout/App.tsx
import { Button, Card, CardHeader, Grid, IconButton, TextField } from "@mui/material";
import { useReplicant } from "../../hooks";
import { NodecgAssets } from "../../replicant";
import { Close } from "@mui/icons-material";
import { FormEvent, useRef } from "react";

function App() {
    const [ telop, setTelop ] = useReplicant<"telop">("telop");
    const telopRef = useRef<HTMLInputElement>(null);
    const submitTelop = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        if (telopRef.current) {
            setTelop(telopRef.current.value);
        }
    }

    // 背景ファイル周りの設定
    const [ , setBackground ] = useReplicant<"background">("background");
    const [ backgrounds, setBackgrounds ] = useReplicant<"assets:background">("assets:background");
    const deleteBG = (bg: NodecgAssets) => {
        // Replicantを更新することでassetsからファイルを削除できる。
        setBackgrounds([...backgrounds.filter(item => bg["url"] !== item["url"])])
    }
    return (
        <Grid container>
            <Grid item xs={12}>
                <form onSubmit={submitTelop}>
                    <TextField multiline margin="dense" label="テロップ" fullWidth defaultValue={telop} inputRef={telopRef} />
                    <Button variant="contained" type="submit">更新</Button>
                </form>
            </Grid>
            <Grid item xs={12}>
                <Grid container justifyContent="center" alignItems="flex-start">
                {backgrounds && backgrounds.map(bg => 
                    <Grid item className="img-preview-container">
                        <Card className="img-preview">
                            <CardHeader subheader={bg['name']} action={<IconButton size="small" onClick={() => {deleteBG(bg)}}><Close /></IconButton> } />
                            {bg['ext'] === ".mp4" ? <video title={bg['name']} src={bg['url']} onClick={() => setBackground(bg)} /> :
                            <img title={bg['name']} alt={bg['name']} src={bg['url']} onClick={() => {setBackground(bg)}} />}
                        </Card>
                    </Grid>
                )}
                </Grid>
            </Grid>
        </Grid>
    )
}
export default App
```

```src/dashboard/layout/index.css
.img-preview {
    margin: 10px;
    position: relative;
}

.img-preview > img,
.img-preview > video {
    max-width: 300px;
}

.img-preview > img:hover,
.img-preview > video:hover {
    cursor: pointer;
}
```

配信画面に載せる画像ファイルを選択する部分はファイル名だけ並べていても分かりづらいと思ったのでプレビューを表示させるようにしています。
↓実装イメージ
![NodeCG03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125818/392a5ac7-8947-414b-50fc-9b1cb0766c13.jpeg)


#### graphics

graphicsは特筆して書くこともないので畳んでおきます。
<details>

```src/graphics/layout/App.tsx
import { useReplicant } from "../../hooks";

function App() {
    const [ telop ] = useReplicant<"telop">("telop");
    const [ background ] = useReplicant<"background">("background");
    return (
        <>
            {!background ? null :
            background['ext'] === ".mp4" ? 
                <video src={background['url']} autoPlay loop muted className="background-image" /> : 
                <img src={background['url']} className="background-image" />
            }
            <div id="telop">{telop}</div>
        </>
    )
}
export default App
```

```src/graphics/layout/index.css
body {
    background-color: transparent;
    margin: 0;
    overflow: hidden;
}
  
.background-image {
    width: 1920px;
    height: 1080px;
}

#telop {
    width: 100%;
    font-size: 36px;
    min-height: 128px;
    display: flex;
    align-items: center;
    color: #f0f0f0;
    background-color: rgba(16, 16, 16, 0.8);
    padding: 10px;
    position: fixed;
    bottom: 0;
}
```

</details>

## 成果物

ビルドした後の実装イメージです。
プレビュー画像をクリックすることで配信画面の背景画像を変更できるようになりました。

![NodeCG04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125818/5019f677-264c-8987-19d5-ca5e5bc5cc8b.gif)

## おわりに

さて、今回はNodeCGダッシュボードで背景画像を変更するためassets機能を利用しましたが、assetsについてまとめている記事が全然見つからずにかなり苦戦しました（assetsについて分かりやすくまとめている記事があればぜひコメントで教えてください・・・）。


## 参考

https://www.nodecg.dev/ja/docs/assets

https://zenn.dev/yagamuu/articles/how-to-make-nodecg-bundle-in-2024