React + Electron製の絵コンテエディタアプリ「Mizutama Conte」を支える技術

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/301047/df6eeb8c-1435-cf4b-a12d-6d9f06ed8345.png)

#### リポジトリ

https://github.com/studio-mizutama/MizutamaConte

#### Web版

https://studio-mizutama.github.io/MizutamaConte/

絵コンテはアニメ制作における設計図です。カット割り、セリフ、トランジション、カメラワーク等、アニメ制作を進める上で必要な情報がぎっしり詰まっています。従来（そして多くの場合現在も）絵コンテは紙とペンで作られてきました。

アニメ制作では各工程において、タイムシート、脚本、字幕、レイアウト等、様々な形式のデータを使用しますが、これらのデータは全て絵コンテを元に作られます。何度も再利用されるデータの管理は、デジタルの最も得意とすることろです。そこで、スタジオみずたまではアニメ制作の経験を生かし、デジタル絵コンテエディタアプリを開発することにしました。

## 先行アプリ

### [Storyboard Pro](https://toonboom.co.jp/products/storyboardpro)

新海誠さんも使ってるプロプライエタリアプリです。あまりにも高額で自主制作への導入はハードルが高いです。

### [Storyboarder](https://wonderunit.com/storyboarder/)

無料で使える海外のOSSです。Electron製です。高機能ですが、カメラワークやトランジションを設定できない等、痒いところに手が届かない部分があります。

### [e-Conte Board](https://apps.apple.com/jp/app/e-conte-board/id1476400416)

iPadアプリです。日本の映像制作において必要十分な機能が揃っていますが、iPad専用のため、データの共有が難しい部分があります。

### [Griffith](https://research.archinc.jp/griffith/)

Webベースのアプリです。一般公開はされていないようです。

## Mizutama Conteで実現したいこと

### いつでもどこでもプレビューできる

絵コンテはいろんな工程でいろんな立場の人が参照します。専用ソフトがなくてもブラウザだけでプレビューできるよう、`json`ファイルと`psd`ファイルで絵コンテファイルを設計しました。

### 作画は使い慣れた外部アプリで

CLIP STUDIO PAINTやPhotoshop等、使い慣れたペイントアプリは人それぞれです。誰もが満足する描き心地を実装することは不可能であり、また、絵コンテ制作のためだけに普段と異なる環境で作画することはストレスになり得ます。そこで、このアプリでは敢えてペイント機能は実装せず、作画はPSD形式に対応した好みの外部アプリを連携させる方針にしました。

### バージョン管理

コアとなる情報を`json`ファイルに記述することで、`Git`によるバージョン管理が可能になります。

# 開発を支える技術の紹介

## [React Spectrum](https://react-spectrum.adobe.com/react-spectrum/index.html)

Adobeのデザインシステム「[Spectrum](https://spectrum.adobe.com/)」のReactコンポーネントです。

基本的に既存のコンポーネントを使いましたが、足りない部分は独自にコンポーネントを作っています。

### 例1：アプリフレーム

```tsx
const BackGround = styled.div`
  width: 100%;
  height: 100%;
  overflow: hidden;
  margin: 0;
  padding: 0;
  background-color: var(--spectrum-alias-appframe-border-color);
`;

const ToolArea = styled.div<{ gridArea: string }>`
  background-color: var(--spectrum-alias-toolbar-background-color);
  grid-area: ${({ gridArea }) => gridArea};
  height: 100%;
  overflow: hidden;
`;

const GlobalGrid: React.FC = ({ children }) => (
  <Provider theme={defaultTheme}>
    <GlobalStyle />
    <BackGround>
      <Grid
        areas={['header header header', 'toolbar content sidebar']}
        columns={['size-600', 'auto', 'size-3600']}
        rows={['size-500', 'auto']}
        height="100vh"
        gap="size-25"
      >
        {children}
      </Grid>
    </BackGround>
  </Provider>
);
```

![ 2021-07-26 at 19.45.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/301047/7ebb42e6-9bfb-7d35-adac-eafef7e9a467.png)

### 例2：アコーディオンメニュー

```tsx
const LabelHover = styled.div`
  width: 100%;
  margin: var(--spectrum-global-dimension-size-50, var(--spectrum-alias-size-50)) 0;
  padding: var(--spectrum-global-dimension-size-50, var(--spectrum-alias-size-50)) 0;
  :hover {
    background-color: var(--spectrum-alias-highlight-hover);
    border-radius: var(--spectrum-global-dimension-size-50, var(--spectrum-alias-size-50));
  }
`;

const Toggle = styled.input`
  display: none;
`;

const Ul = styled.ul`
  padding-left: var(--spectrum-global-dimension-size-300, var(--spectrum-alias-size-300));
  margin: 0;
`;

const Label: React.FC<{ labelFor: string }> = ({ labelFor, children }) => (
  <LabelHover>
    <label htmlFor={labelFor}>{children}</label>
  </LabelHover>
);

export const Accordion: React.FC<{ labelName: string }> = ({ labelName, children }) => {
  const [toggle, setToggle] = useState(false);
  return (
    <>
      <Toggle type="checkbox" id={labelName} checked={toggle} onClick={() => setToggle(!toggle)} />

      <Label labelFor={labelName}>
        <Flex direction="row" gap="size-100" alignItems="center">
          {toggle ? <ChevronDown size="S" /> : <ChevronRight size="S" />}
          <Text>{labelName}</Text>
        </Flex>
      </Label>

      <Ul style={{ display: `${toggle ? 'block' : 'none'}` }}>{children}</Ul>
    </>
  );
};
```

![ 2021-07-26 at 19.48.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/301047/a41afc59-257b-f412-f6de-f0f368bce575.png)

## [ag-psd](https://github.com/Agamnentzar/ag-psd)

PSDファイルを読むためのJavaScriptライブラリです。`Psd`型のオブジェクトとしてPSDファイルを扱うことができます。

```js
{
  "width": 300,
  "height": 200,
  "channels": 3,
  "bitsPerChannel": 8,
  "colorMode": 3,
  "children": [
    {
      "top": 0,
      "left": 0,
      "bottom": 200,
      "right": 300,
      "blendMode": "normal",
      "opacity": 1,
      "transparencyProtected": false,
      "hidden": true,
      "clipping": false,
      "name": "Layer 0",
      "canvas": [Canvas]
    },
    {
      "top": 0,
      "left": 0,
      "bottom": 0,
      "right": 0,
      "blendMode": "multiply",
      "opacity": 1,
      "transparencyProtected": true,
      "hidden": false,
      "clipping": false,
      "name": "Layer 3",
      "canvas": [Canvas]
    }
  ],
  "canvas": [Canvas]
}
```

canvas部分はHTML5の`HTMLCanvasElement`です。ReactのJSXにはそのまま書けないため、`toDataURL`してから`img`要素の中で表示させます。

```tsx
<View gridArea="picture" width="100%" height="auto">
                    {cut.picture?.children
                      ?.filter((child: Psd['children'], layerindex: number) => layerindex !== 0)
                      .map((child: Layer) => {
                        const src = child.canvas?.toDataURL('image/png', 0.4);
                        return (
                          <div
                            style={{
                              height: `${child.canvas && child.canvas.height * 0.12}px`,
                              width: `${child.canvas && child.canvas.width * 0.12}px`,
                              backgroundColor: '#fff',
                              position: 'relative',
                            }}
                          >
                            <img
                              style={{ transform: 'scale(0.12)', transformOrigin: 'left top' }}
                              src={src}
                              alt="cut"
                            />
                        );
                      })}
</View>
```

## [ReactN](https://github.com/CharlesStover/reactn)

`useGlobal`でグローバル状態管理を可能にするエクステンションです。使い方は`useState`とほぼ同じです。

```index.tsx
import React, { setGlobal } from 'reactn';
import ReactDOM from 'react-dom';
import App from 'App';
import reportWebVitals from 'reportWebVitals';
import { Psd } from 'ag-psd';

const prtPsd: Psd = { width: 1, height: 1 };
const prtCut: Cut = {
  picture: prtPsd,
};

setGlobal({
  mode: 'Edit',
  tool: new Set(['Select']),
  cut: prtCut,
  globalCuts: [prtCut],
  globalPsds: [prtPsd],
  globalFileName: '',
});
```

`index.tsx`で`setGlobal`を宣言します。

```global.d.ts
import 'reactn';

declare module 'reactn/default' {
  export interface State {
    mode: string;
    tool: Set<string> | undefined;
    cut: Cut;
    globalCuts: Cut[];
    globalPsds: Psd[];
    globalFileName: string;
  }
}
```

`global.d.ts`で型情報を記述します。

```tsx
const CutContainer: React.FC = () => {
  const prtPsd: Psd = { width: 1, height: 1 };
  const prtCut: Cut = {
    picture: prtPsd,
  };
  const [cuts, setCuts] = useState([prtCut]); 
  const globalCuts = useGlobal('globalCuts')[0];
  const globalPsds = useGlobal('globalPsds')[0];
//略
}
```

あとは`useState`のようにコンポーネント内で記述するだけです。

## [react-hotkeys-hook](https://github.com/JohannesKlauss/react-hotkeys-hook)

`useHotKeys`でキーボードショートカットを簡単に実装できます。

```tsx
const [selected, setSelected] = useGlobal('mode');

useHotkeys('e', () => {
  setSelected('Edit');
});

useHotkeys('p', () => {
  setSelected('Preview');
});
```

## [react-typescript-electron-sample-with-create-react-app-and-electron-builder](https://github.com/yhirose/react-typescript-electron-sample-with-create-react-app-and-electron-builder)

今回のアプリの雛形として使用しました。
メイン・レンダラーいずれのプロセスもホットリロード対応で、最高のDXです。

https://qiita.com/yhirose/items/22b0621f0d36d983d8b0