GoogleスライドとOpen JTalkで動画作成

#なぜ作ろうと思ったか
* バイト先の一つであるプログラミング教室で，学習用に使う動画を作りたかったから．
* 動画を作りたいけど，顔出しや声出しは恥ずかしいと言う人はざらにいるはず．そう言う人が手軽に動画を作れたら便利ではないかと思ったから．

#何をしたのか
Googleスライドとは，Googleが作成したプレゼンテーションソフトです．ブラウザとGoogleアカウントがあれば，誰でもプレゼンテーションを作成することができます．今回，このGoogleスライドを用いてスライド動画を作成します．
Googleスライドでは，下図のようにスライドとその下にノート（話す内容などをメモするところ）があります．このスライドとノートを用いて，スライドに合わせて合成音声（Open JTalk）が話すスライドショーのような動画を作成します．
<img width="1383" alt="スクリーンショット 2019-11-30 21.00.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/10f77269-e831-2bb2-ca9e-6d6706e5149c.png">
実際に作成した動画はこちら．
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">GoogleスライドとOpen JTalkで動画を作ってみた<br>（↓サンプル） <a href="https://t.co/qvN9no65kS">pic.twitter.com/qvN9no65kS</a></p>&mdash; manaco (@mamamana26) <a href="https://twitter.com/mamamana26/status/1200750276103176193?ref_src=twsrc%5Etfw">November 30, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


#やったこと
* Googleスライドから，スライド画像とノートを保存するGoogle Apps Scriptの作成
* Pythonで，動画生成プログラムの作成
* 詳しいコードは[github](https://github.com/tkd26/GoogleSlide_VideoCreator)にあります

#1. Google Apps Scriptの作成と実行

以下のコードをアドオンとして実行しました．実行方法は，サンプル動画にある通りなので見てみてください．

```javascript:test.gs
function onInstall(event) {
  onOpen(event);
}

function onOpen(event) {
  var ui = SlidesApp.getUi();

  Logger.log(Session.getActiveUserLocale());

  ui.createMenu('追加メニュー')
    .addItem('保存', 'saveNoteAndImages')
    .addToUi();
}
  
function saveNoteAndImages() {
  var ui = SlidesApp.getUi();
  
  var result = ui.prompt(
    'スライドとノートを保存しますか?',
    'ファイル名を変えたい場合は入力してください．',
    ui.ButtonSet.OK_CANCEL);
  
  var presentation = SlidesApp.getActivePresentation();
  var presentationName = presentation.getName();
  
  var button = result.getSelectedButton();
  var text = result.getResponseText();
  if (button == ui.Button.OK) {
    saveScenarioSlideImages(text ? text : presentationName);
  } else if (button == ui.Button.CANCEL) {
  } else if (button == ui.Button.CLOSE) {
  }
}
  
function downloadSlide(folder, name, presentationId, slideId) {
  var url = 'https://docs.google.com/presentation/d/' + presentationId + '/export/jpeg?id=' + presentationId + '&pageid=' + slideId;
  var options = {
    headers: {
      Authorization: 'Bearer ' + ScriptApp.getOAuthToken()
    }
  };
  var response = UrlFetchApp.fetch(url, options);
  var image = response.getAs(MimeType.JPEG);
  image.setName(name);
  folder.createFile(image);
}
  
function saveScenarioSlideImages(presentationName) {
  var presentation = SlidesApp.getActivePresentation();
  var scenario = [];
  var folder = DriveApp.createFolder(presentationName);
  presentation.getSlides().forEach(function(slide, i) {
    var pageName = Utilities.formatString('%03d', i+1)+'.jpeg';

    var txt = '';
    slide.getNotesPage().getShapes().forEach(function(shape, i) {
      txt += shape.getText().asString();
    });
  
    var note = [];
    txt.split('\n').map( function(t) { return t.trim() } ).forEach( function(v) {
      if (v == '') {
        //note.push(v);
      } else {
        note.push(v);
      }
    });
  
    scenario = scenario.concat(note);
    scenario.push(':newpage');
    downloadSlide(folder, pageName, presentation.getId(), slide.getObjectId());

  });
  folder.createFile('text.txt',scenario.join('\n'));
}
```

アドオンを実行すると，自分のドライブのホームにフォルダが作成されます．フォルダの中身は，スライド画像とノートの内容をまとめたテキストファイルです．これらのファイルを自分のPC上にダウンロードしてください．
<img width="1137" alt="スクリーンショット 2019-11-30 21.24.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364169/16a227c9-52ea-acbc-fb94-c4a63cbb4219.png">

text.txtの中身はこのようになっています．
`:newpage`で次のスライドへ進めます．`:1`は処理を停止する時間を表し，この例では1秒待ちます．話すときに間を作りたい時などに使用できます．

```:text.txt
こんにちは．
:1
今回は，グーグルスライドで自作スクリプトを実行する方法について紹介します．
:newpage
はじめに，グーグルスライドを開き，ツールからスクリプトエディタを選択します．
:newpage
```


#2.動画生成プログラム

スライドをめくるタイミングと音声のタイミングを合わせるために，1スライドごとに無音動画と音声を作成し，それを連結して各スライドの音あり動画を作成した後に，さらにそれらを連結することで1本の動画にします．

流れ：
各フレーズの音声生成→連結して1スライド分の音声作成→音声の長さと同じ長さの無音動画を生成→音声と無音動画を結合して音あり動画を生成→これを繰り返して全スライド分音あり動画を生成→全ての音あり動画を連結

コードのリンクは上に載せたので，使用したいくつかの関数の機能を，軽く紹介だけします．

対応オプションは以下のようになってます．

```python
def get_args():
    parser = argparse.ArgumentParser()
    parser.add_argument('input', type=str, help='input folder path')
    parser.add_argument('-o','--output', type=str, default='out.mp4', help='output file name')
    parser.add_argument('-f','--framerate', type=int, default=46000, help='sound frame rate')
    parser.add_argument('-s','--speed', type=float, default=1.0, help='sound speed')
    args = parser.parse_args()
    return args
```

`get_NoteList()`はテキストファイルからテキストを取り出し，スライド番号に対応するようにリストに格納します．

```python
def get_NoteList(fpath):
    f = open(fpath)
    text = f.read()
    f.close()
    text = text.split('\n')
    note_list = []
    note = []
    for line in text:
        if line==':newpage':
            note_list += [note]
            note = []
        else:
            note += [line]
    return note_list
```

`make_Sound()`は，Open JTalkを用いて音声を作成します．サイレント音声部分は`pydub`の`AudioSegment`で作成しました．

```python
def make_Sound(args, text, fname):
    if line[0]==':': # silent
        sound_len = float(line[1:]) * 1000
        sound = AudioSegment.silent(duration=sound_len, frame_rate=args.framerate)
        sound.export(fname, format="wav")
    else: # talk
        open_jtalk = ['open_jtalk']
        mech = ['-x', '/usr/local/Cellar/open-jtalk/1.11/dic']
        htsvoice = ['-m', '/usr/local/Cellar/open-jtalk/1.11/voice/mei/mei_normal.htsvoice']
        speed = ['-r', str(args.speed)]
        sampling = ['-s', str(args.framerate)]
        outwav = ['-ow', fname]
        cmd = open_jtalk + mech + htsvoice + speed + sampling + outwav
        c = subprocess.Popen(cmd, stdin=subprocess.PIPE)
        c.stdin.write(text.encode('utf-8'))
        c.stdin.close()
        c.wait()
```

`join_Sound()`は`ffmpeg`を使用して音声を生成します．

```python
def join_Sound(i, fname):
    sound_path_fname = './sound/tmp{:03}/sound_path.txt'.format(i)
    sound_list = sorted(glob.glob(os.path.join('./sound/tmp{:03}'.format(i), '*.wav')))
    sound_path = ''
    for line in sound_list:
        sound_path += 'file ' + os.path.split(line)[-1] + '\n'
    with open(sound_path_fname, mode='w') as f:
        f.write(sound_path)
    cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', sound_path_fname, '-loglevel', 'quiet', '-c', 'copy', fname]
    c = subprocess.call(cmd)
```

`make_SilentVideo()`はそのスライドの音声ファイルと同じ長さの無音動画を作成します．

```python
def make_SilentVideo(slide, sound_len, fname):
    img = cv2.imread(slide)
    h, w = img.shape[:2]
    fourcc = cv2.VideoWriter_fourcc('m','p','4', 'v')
    video  = cv2.VideoWriter(fname, fourcc, 20.0, (w,h))
    framecount = sound_len * 20
    for _ in range(int(framecount)):
        video.write(img)
    video.release()

```

`join_SilentVideo_Sound()`は無音動画と音声を結合して1スライド分の音声あり動画を作成します．`join_Video()`は，作成した各スライドの音あり動画を連結して1本の動画にします．どちらも`ffmpeg`を使用しました．

```python

def join_SilentVideo_Sound(silent_video, sound, fname):
    cmd = ['ffmpeg', '-y', '-i', silent_video, '-i', sound, '-loglevel', 'quiet', './video/{:03}.mp4'.format(fname)]
    c = subprocess.call(cmd)
```

```python
def join_Video(args):
    video_path_fname = './video/video_path.txt'
    video_list = sorted(glob.glob(os.path.join('./video', '*.mp4')))
    video_path = ''
    for line in video_list:
        video_path += 'file ' + os.path.split(line)[-1] + '\n'
    with open(video_path_fname, mode='w') as f:
        f.write(video_path)
    cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', video_path_fname, '-loglevel', 'quiet', '-c', 'copy', args.output]
    c = subprocess.call(cmd)
```

#実行

`main.py`に入力ファイル名`./test`を渡して実行します．`./test`の中には，Googleドライブからダウンロードしたスライド画像と，ノートの内容が記録されたテキストファイルがあります．

```
$ python main.py ./test
```
実行すると，`out.mp4`と言う出力ファイルがカレントディレクトリに作られます．