なぜ作ろうと思ったか
- バイト先の一つであるプログラミング教室で,学習用に使う動画を作りたかったから.
 - 動画を作りたいけど,顔出しや声出しは恥ずかしいと言う人はざらにいるはず.そう言う人が手軽に動画を作れたら便利ではないかと思ったから.
 
何をしたのか
Googleスライドとは,Googleが作成したプレゼンテーションソフトです.ブラウザとGoogleアカウントがあれば,誰でもプレゼンテーションを作成することができます.今回,このGoogleスライドを用いてスライド動画を作成します.
Googleスライドでは,下図のようにスライドとその下にノート(話す内容などをメモするところ)があります.このスライドとノートを用いて,スライドに合わせて合成音声(Open JTalk)が話すスライドショーのような動画を作成します.

実際に作成した動画はこちら.
GoogleスライドとOpen JTalkで動画を作ってみた
— manaco (@mamamana26) November 30, 2019
(↓サンプル) pic.twitter.com/qvN9no65kS
やったこと
- Googleスライドから,スライド画像とノートを保存するGoogle Apps Scriptの作成
 - Pythonで,動画生成プログラムの作成
 - 詳しいコードはgithubにあります
 
1. Google Apps Scriptの作成と実行
以下のコードをアドオンとして実行しました.実行方法は,サンプル動画にある通りなので見てみてください.
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上にダウンロードしてください.

text.txtの中身はこのようになっています.
:newpageで次のスライドへ進めます.:1は処理を停止する時間を表し,この例では1秒待ちます.話すときに間を作りたい時などに使用できます.
こんにちは.
:1
今回は,グーグルスライドで自作スクリプトを実行する方法について紹介します.
:newpage
はじめに,グーグルスライドを開き,ツールからスクリプトエディタを選択します.
:newpage
2.動画生成プログラム
スライドをめくるタイミングと音声のタイミングを合わせるために,1スライドごとに無音動画と音声を作成し,それを連結して各スライドの音あり動画を作成した後に,さらにそれらを連結することで1本の動画にします.
流れ:
各フレーズの音声生成→連結して1スライド分の音声作成→音声の長さと同じ長さの無音動画を生成→音声と無音動画を結合して音あり動画を生成→これを繰り返して全スライド分音あり動画を生成→全ての音あり動画を連結
コードのリンクは上に載せたので,使用したいくつかの関数の機能を,軽く紹介だけします.
対応オプションは以下のようになってます.
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()はテキストファイルからテキストを取り出し,スライド番号に対応するようにリストに格納します.
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で作成しました.
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を使用して音声を生成します.
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()はそのスライドの音声ファイルと同じ長さの無音動画を作成します.
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を使用しました.
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)
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と言う出力ファイルがカレントディレクトリに作られます.