Google Apps ScriptでChatGPTを叩いてスライドを生成する【GAS】

Gemini for Google Workspaceが日本語対応でリリースされていますが、機能面では正直な所残念なのが多い(Googleの中の人が現場のユーザの使い所とか知らないんだろうなぁ)。その代表的な1例であり、すでにChatGPTやClaudeでは実現済みなのが「要望したスライドを一式自動生成」する機能。

Gemini for Google Workspaceだと1枚っきり生成でドヤ顔プレゼンしていましたが・・・・。また、ChatGPTやClaudeでも1枚1枚のスライドにテーマに沿った画像までは生成して埋め込めない。なのでChatGPT APIを利用してこれを実装してみました。Gemini APIやClaude 3.5 Sonnetをバックに使っても実現可能です。

今回利用するファイル等

今回のサンプルはOpenAI ChatGPT APIをバックエンドに利用していますので、APIキーが必要になります。利用するモデルは以下の2つを利用します。今回のサンプルは「植物の栽培法」にプロンプトが特化してるので注意。

Geminiでも最近、Imagen3という画像生成が装備されたので、バックエンドを変えてあげれば同様にスライド一式生成と画像埋め込みまで一気に自動化することが可能です。尚、画像はStandard画質では1枚あたり15秒で生成されます。なぜ、Gemini for Google Workspaceは出し惜しみをしてるのかわからない。出遅れてるのに。

Gemini for Google Workspaceの実力ってどれだけあるのか?

事前準備

スライドの準備

今回のサンプルはすでに生成済みの状態になっていますが、このまま重ねて実行するとオカシナことになりますので以下の作業を必ず行います。なんだったら、生成時にはこれらの作業も自動で行うように実装すると良いでしょう。

  • 1枚目の中身だけ削除する(テキストボックスなど)
  • 2枚目以降はスライド自体を全削除

図:まずはスライドの中身を消去しましょう。

支払いをしてクレジットをチャージする

今回は課金してAPIキーを利用します。OpenAIの課金はクレジットカードで行いますが、課金したものを1年放置すると消えてしまうようなので、課金して放置しておくと使えなくなってしまいます。また課金額は5ドルからの従量課金を自分でセットすることができますし、オートチャージも可能です。

今回の開発でかなり画像やスライド生成リクエストしてるのですが($20課金してます)、それでもまだ17$くらい残ってるので相当リクエストすることができます。月額2000円みたいな下値が決まってるのと違って、ライフスタイルに合わせられるのは嬉しい点。

  1. こちらのサイトにログインする
  2. Add to Credit balanceをクリックする
  3. 金額を入力する(ドルベースです)
  4. Add Payment Methodをクリックしてカードを登録する
  5. Payment Methodでカードを選ぶ
  6. Continueして確認したら続行すると自分のアカウントに課金額がチャージされます。

Enable Auto Rechargeを有効化すると、課金額を使い切った時点で自動的に同額のチャージがされてカードから請求が走るようになっています。

図:自分で課金額設定できる仕様

API Keyを生成する

Google Apps ScriptでOpenAI ChatGPT APIを利用する為のAPIキーを生成します。以下の手順で生成しましょう。

  1. こちらのダッシュボードをまず開く
  2. 左サイドバー一番下にある「API Keys」をクリックする
  3. 右上にあるCreate New Secret Keyをクリックする
  4. ダイアログが出たら適当な名前を入れます。
  5. ProjectはとりあえずDefaultのままで良いでしょう。Permissionも取り敢えずAllのままでオッケー。
  6. Create Secret keyをクリックする
  7. Save your Keyでキーが表示される。この時しか出てこないので注意。コピーしておきましょう。

これでAPI Keyは準備できました。

図:APIキーを生成しましょう

プログラムを作成する

本来あるべき姿

今回のプログラムは1つのスライドファイルに直接GASを記述していますが、その特性上本来は「アドオン化」して、すべてのスライドで使えるようにしておくのが得策です。また、スライドのテンプレートをそのまま引き継ぎますので、色々なファイルにコードがあるよりも、アドオンとしておくことで、自分の好きなテンプレでファイルが生成可能です。

以下のエントリーはスプシの場合のアドオンの作成方法ですが大きく変わらないので、これを参考にアドオンとして構築し、組織内限定で配布すると捗るでしょう。

Google Spreadsheet用の組織内アドオンを作成する【GAS】

スクリプトプロパティの準備

今回は特に設定保存用の機能を装備していないので、スクリプトプロパティに直接設定値を書き込みます。

  • apikeyというプロパティを用意して、前述で用意したAPIキーの値を入れる
  • folderidというプロパティを用意して、スライド生成先や画像保存先として利用するフォルダのIDを入れる

これでGAS側の事前準備はオッケー。

図:スクリプトプロパティに設定値を書き込む。

ソースコード

スライド生成

概要

GPTを使ってのスライド生成については、すでにZennにて同じことを実現してる方がいるので、そちらのソースコードをベースにして以下の機能を追加装備しました。

  • スライド生成するタイトル入力をするダイアログを装備する。
  • ChatGPT APIを利用してスライド元データは自動生成させる。
  • 今回コメントアウトしていますが、アドオン化した場合の自動生成スライドを出力を保存する先の指定をできるようにする。
  • 各スライドの箇条書き部分は独立したテキストボックスとして、箇条書きのボックスに変更する
  • 箇条書きボックスの行間隔を1.15とし、12ポイントで設定するよう書式変更。また箇条書きの頭文字は☑️タイプに変更。
  • imagenflgをグローバル変数で用意し、後述の画像生成も同時に行えるようにする

これらを踏まえてコードを改造します。

生成プロンプト

今回は汎用ではなく、「植物の栽培法」に関するスライドを自動生成するようにしてるので、ダイアログボックスでは植物の名前を入れてOKを押すだけで一気に生成するようにしています。

故にプロンプトは以下のようなスタイルにしています。

let slider = str + "の栽培法について、PowerPointのスライドを作成してください。但し以下の条件をすべて守ってください\n"
                 + "・レスポンスはマークダウン形式で表示"
                 + "・料理などの応用事例についても生成してください。"
                 + "・「スライド1:」といった表記は不要です。マークダウンとタイトルを残して下さい"
                 + "・「以上が」といった余計なコメントは説明は不要です"
                 + "・回答には「---」や「```」といった文字は含めないでください"
  • strにはダイアログの入力欄の植物の名前が入ってきます。
  • 値はマークダウン形式で受け取ります
  • 応用事例として料理法についても入れるように追加の指示を出しています。
  • 生成内容に「スライド1 : 」といったような余計な文言が含まれるのでこれを除外するように指示を出しています。
  • 生成内容にありがちな「余計なコメント」についても出力しないように指示を出しています。
  • またこれもありがちですが、「---」や「```」というスライドデータ内容の上下に出てくる余計なワードも除外しています(これデフォルトで出ないようにしてくれないんですかね・・・邪魔)
ソースコード

Zennで掲示されてるコードを改造して以下のようになりました。

  • 途中コメントアウトしてる部分は現在のスライドではなく、複製してそちらに生成する場合に利用します。
  • 冒頭でリクエストエンドポイントと利用するモデルを指定。また、画像生成も行う場合はimagenflgをTrueにしておきます。
  • 箇条書き部分はapplyListPreset(SlidesApp.ListPreset.CHECKBOX)でチェックボックススタイルにしています。他にもプリセットは用意されていますが、絵文字などのカスタムはできません。
  • setSpacingMode(SlidesApp.SpacingMode.NEVER_COLLAPSE)でもって、箇条書きの場合の行間隔に対して詳細な値をセットできます。指定しないと行間隔が詰まっていて不恰好なので。故にメソッドチェーンで色々と指定が入っています。
  • 画像の連続生成は同時に行うとスクリプトが非同期なので生成に間に合わず、オカシナ状態になるため、スライドのIDを格納した配列をもってして、最後に一括で対象IDのスライドと内容を元に同期的に生成するようにしています(Promise.All内の「現在のスライドに追加する」の部分がソレ)
//ChatGPT API リクエストエンドポイント
const apiUrl = 'https://api.openai.com/v1/chat/completions';

//利用するモデル
const model = "gpt-4o-mini";

//画像連続生成するかどうか?
const imagenflg = false;

//メニューを作成
function onOpen(e) {
  let ui = SlidesApp.getUi();
  ui.createMenu('▶GPTスライダー')
    .addItem('スライド作成開始', 'makegptslide')
    .addItem('画像生成開始', 'makeGptImage')
    .addToUi();
}

//連続で画像を生成する(slideid, タイトル、スライドタイトルの3つで構成)
function generateman(array){
  let sp = SlidesApp.getActivePresentation();

  let ret = Promise.all(array.map(async rec => {
    //キーワード生成
    let keyword = rec[1] + "の" + rec[2];

    //画像生成とファイルのIDを取得
    let imageId = await dallImageGenerator(keyword,true,rec[0]);

    //生成された画像のblobを取得
    let blob = DriveApp.getFileById(imageId).getBlob();

    //現在のスライドに追加する
    let slide = sp.getSlideById(rec[0])
    slide.insertImage(blob).setWidth(300).setHeight(200).setLeft(350).setTop(80);

    //waitを入れる
    Utilities.sleep(1000)
  })).then( () => {
      //無事終了
      return 0;
  } ) 
  .catch( function (error) {
      //エラーを返す
      return error;
  } ) ;
}

//スライド作成で呼び出される関数
function makegptslide(){
  let ui = SlidesApp.getUi();

  //スライドのテーマを入力
  let ret = ui.prompt("スライド作成のテーマを入力して下さい。", 
      ui.ButtonSet.OK_CANCEL);
  
  //押されたボタンによって処理を分岐
  let str = "";
  switch(ret.getSelectedButton()){
    //OKボタンを押した時の処理
    case ui.Button.OK:
      //入力値を取得する
      str = ret.getResponseText();
      break;
    //キャンセルを押した時の処理
    case ui.Button.CANCEL:
      ui.alert("何もせずに閉じました。");
      return;
    case ui.Button.CLOSE:
      return;
  }

  //スライド作成を実行する
  let result = createSlideFile(str);
  
  //画像連続生成
  if(imagenflg == true){
    let final = generateman(result)
    //終了メッセージ
    ui.alert("スライド生成完了");
    return
  }
  
  //終了メッセージ
  ui.alert("スライド生成完了")
}

//プレゼン資料を作成するメイン関数
function createSlideFile(str) {
  //API Keyを取得する
  let prop = PropertiesService.getScriptProperties();
  let apikey = prop.getProperty("apikey");
  let folderid = prop.getProperty("folderid");

  //スライド作成指示
  let slider = str + "の栽培法について、PowerPointのスライドを作成してください。但し以下の条件をすべて守ってください\n"
                   + "・レスポンスはマークダウン形式で表示"
                   + "・料理などの応用事例についても生成してください。"
                   + "・「スライド1:」といった表記は不要です。マークダウンとタイトルを残して下さい"
                   + "・「以上が」といった余計なコメントは説明は不要です"
                   + "・回答には「---」や「```」といった文字は含めないでください"

  //スライド生成要素
  let payload = JSON.stringify({
    model: model,
    max_tokens : 2048,
    temperature: 0.9,
    messages: [{ role: 'assistant', content: slider }],
  });

  //リクエストオプション
  options = {
    method: 'post',
    headers: {
      Authorization: "Bearer " + apikey,
      'Content-Type': 'application/json',
    },
    muteHttpExceptions: true,
    payload,
  };

  //HTTPリクエスト
  let response = UrlFetchApp.fetch(apiUrl, options);

  //返答を取得する
  let content = response.getContentText();
  let jsn = JSON.parse(content);
  let res = jsn.choices[0].message.content;

  //現在のスライドを取得
  let presen = SlidesApp.getActivePresentation();
  //let slideid = presen.getId();

  //スライドを複製する
  //let target = DriveApp.getFolderById(folderid);
  //slideid = DriveApp.getFileById(slideid).makeCopy(str + "栽培法", target).getId();

  //複製スライドを取得
  //presen = SlidesApp.openById(slideid);
  let slides = presen.getSlides();

  //最初のスライドを削除
  slides[0].remove();

  //現在のスライドのレイアウトを取得
  let layouts = presen.getLayouts();

  //見出しでテキストを分割し、スライドごとに処理、配列で返す
  //**をリプレースして、見出しレベル毎に1行の配列に
  const slideTexts = res.trim().split('\n').reduce((acc, line) => {
    if (line.startsWith('# ') || line.startsWith('## ')) {
      acc.push(line.replace(/\*\*/g, ''));
    } else {
      acc[acc.length - 1] += '\n' + line.replace(/\*\*/g, '');
    }
    return acc;
  }, []);

  //箇条書きボックス追加フラグ
  let insertflg = true;

  //生成結果格納用配列
  let array = [];

  slideTexts.forEach((slideText, index) => {
    //改行コードで分割する
    const elements = slideText.split('\n');

    //処理用の各種配列
    let yOffset = index === 0 ? 200 : 40;
    let textBox;
    let slide;
    let title;
    let subtitle;

    elements.forEach((element) => {
      //要素が空っぽの場合終了
      if (!element.trim()) {
        return;
      }

      //マークダウンの要素によって処理を分岐
      switch(true){
        //スライドタイトル
        case element.startsWith('# '):
          // 新しいスライドを追加
          slide = presen.appendSlide(layouts[0]);

          //タイトルの挿入と装飾
          title = element.substr(2);
          textBox = slide.insertTextBox(title).setTop(yOffset).setWidth(500).setLeft(50);
          textBox.getText().getTextStyle().setFontSize(35).setBold(true);
          break;

        //各スライドタイトル
        case element.startsWith('## '):
          //フラグ更新
          insertflg = true;

          // 新しいスライドを追加
          slide = presen.appendSlide(layouts[2]);

          //文字の挿入と装飾
          subtitle = element.substr(3);
          textBox = slide.insertTextBox(subtitle).setTop(yOffset).setWidth(500).setLeft(50);
          textBox.getText().getTextStyle().setFontSize(24).setBold(true);
          yOffset += 40; 

          //生成結果を格納する
          let temparr = [
            slide.getObjectId(),
            str,
            subtitle
          ]

          array.push(temparr);
          break;

        //リストボックス
        case element.startsWith('- '):
          //箇条書き項目を取得
          const listItem = element.substr(2);

          //箇条書き用ボックスの追加
          if(insertflg == true){
            //ボックス追加
            textBox = slide.insertTextBox(listItem).setTop(yOffset).setWidth(250).setLeft(80).setHeight(200);

            //チェックボックスタイプの箇条書きに
            textBox.getText().getListStyle().applyListPreset(SlidesApp.ListPreset.CHECKBOX);

            //テキストボックスの行間隔を指定
            textBox.getText().getParagraphStyle().setSpacingMode(SlidesApp.SpacingMode.NEVER_COLLAPSE).setLineSpacing(115).setSpaceAbove(12).setSpaceBelow(12);

            //フラグを更新
            insertflg = false;
          }else{
            //項目だけ追加
            textBox.getText().appendText('\n' + listItem);
          }

          yOffset += 20;
          break;
          
        //その他の文字種
        default:
          //それ以外の文字はテキストボックスで追加
          textBox = slide.insertTextBox(element).setTop(yOffset).setWidth(500).setLeft(50);
          textBox.getText().getTextStyle().setFontSize(14);
          yOffset += 40;
          break;
      }
    });
  });
  return array;
}

画像生成

概要

画像生成部分はChatGPTの別のAPIを利用して生成が必要になります。今回利用するのがDall-e-3と呼ばれるモデルで、画質としてはStandardとHDの2つが用意されており、最低解像度は1024x1024からなのでそれより小さい画像はDall-e-2などの旧モデルを利用する必要があります。

特徴としては

  • 商用利用が可能です。許可も必要ありません。ちなみにBing Chatの画像生成は商用利用できません。
  • ただし著作権トラブルに関してはOpenAIは責任を負わないので、利用者の責任となります(この辺がGeminiと違うところ)。
  • 2023年9月21日にリリースされたモデルで、Dall-e-2よりもより深く考えて生成してくれるので同じ質問でも生成画像にかなり差があります。
  • 特にリアルな写実的な画像の生成に関して長けている

Youtube解説動画のようなスポットで使うようなイメージ画像の生成であったり、スライドなんかで使うイメージ画像としては非常に向いてるといえます。

※OpenAIやGeminiでこれらが装備されてしまうと、画像生成専門でやってるStable Diffusionとかはかなりマズイのでは?と思ったり。巨大資本のバックアップが無い専門特化AIは苦しくなると思います(どんな領域もそうですが・・・)

※またStable Diffusionのように奇怪なものはあまり出てこないですが、一方でオカシナ画像も出てくるのも事実。

図:サンプルで生成してみた様子

図:大阪新世界の大阪寿司で出したらコレ・・・

生成プロンプト

今回はシンプルに「入力値に関する画像を生成」とだけ指定しています。ただこれだけだと指示が直球すぎるのと、写実的では無いよくわからない画像も出がちです。

特にスライド生成時に呼び出す場合にはプロンプトは「スライドのテーマ + 各スライドのタイトル」を含めて、上記のプロンプトで生成してるので写実的では無い絵画的なものも出てきたりしますので、この辺りのプロンプトはビジネス利用に向くようにちょっと調整が必要かなぁと思います。

ソースコード

画像生成は単独で使う場合と、前述のスライド生成時に連続で呼び出す場合との2パターンに対応できるように組んでいます。

  • 冒頭で画像の縦横サイズや利用するモデルを変数で指定
  • イメージカウントは1で固定。1枚だけ生成するようにしてる。ただ、サイドバー用意して複数生成して選ぶようなスタイルにする場合は数値を変更する。
  • スライド生成時に連続生成する場合、dallImageGeneratorの2つ目の引数はfalseとし、3つ目の引数にスライドIDを渡して対象スライドに直接生成します。
  • 生成した画像はスクリプトプロパティのfolderidに指定した場所に出力してから埋め込んでいるので、後で再利用も可能です。

図:ドライブに画像は生成されます。

//画像生成用のオプション
let imageSize = '1024x1024'  //Dall-e-3だとこのサイズが最低ライン
let imageCount = 1
const imageModel = "dall-e-3";

//ChatGPTで画像生成して取得する
function makeGptImage() {
  //入力プロンプトを表示
  let ui = SlidesApp.getUi();
  let ret = ui.prompt("生成する画像のテーマを入力してください。", 
      ui.ButtonSet.OK_CANCEL);
  
  //押されたボタンによって処理を分岐
  let keywords = "";
  switch(ret.getSelectedButton()){
    //OKボタンを押した時の処理
    case ui.Button.OK:
      //入力値を取得する
      keywords = ret.getResponseText();
      break;
    //キャンセルを押した時の処理
    case ui.Button.CANCEL:
      ui.alert("何もせずに閉じました。");
      return;
    case ui.Button.CLOSE:
      return;
  }

  //画像生成とファイルのIDを取得
  let imageId = dallImageGenerator(keywords,true);
  
  //生成された画像のblobを取得
  let blob = DriveApp.getFileById(imageId).getBlob();

  //現在のスライドに追加する
  let slide = SlidesApp.getActivePresentation().getSelection().getCurrentPage();
  slide.insertImage(blob).setWidth(300).setHeight(200).setLeft(350).setTop(80);

  //終了処理
  ui.alert("終了しました。")
}

//スライド生成から呼ばれる場合(ファイルIDだけ返す)
function dallImageGenerator(keywords,flg,slidenum=0) {
  //エンドポイントとプロンプトを指定
  const endpoint = 'https://api.openai.com/v1/images/generations';
  prompt = keywords + "に関する画像を生成";

  //リクエストボディを構築
  //qualityはhdも選べるが遅くなる
  const payload = JSON.stringify({
    model: imageModel,
    prompt,
    n: imageCount,
    size: imageSize,
    quality: "standard",
  });

  //APIリクエストしてURLを取得
  let msg = "";
  let imageId;
  const json = generateImage(endpoint, payload);
  if (json.data && json.data.length > 0) {
    //イメージURLを取得
    let imageUrl =  json.data[0].url;

    //画像をダウンロードする
    imageId = generateImageToDrive(imageUrl,keywords);

    //メッセージを生成
    msg = "終了しました。"
  } else {
    msg = json;
  }

  //flgにより処理を分岐
  if(flg == true){
    //ファイルのIDを返す
    return imageId;
  }else{
    //対象のスライドページを取得
    let slide = SlidesApp.getActivePresentation().getSlideById(slidenum);

    //生成された画像のblobを取得
    let blob = DriveApp.getFileById(imageId).getBlob();

    //現在のスライドに追加する
    slide.insertImage(blob).setWidth(300).setHeight(200).setLeft(350).setTop(80);

    //ファイルのIDを返す
    return imageId;
  }
}

//ChatGPTのAPIを叩いて画像生成
function generateImage(endpoint, payload) {
  let prop = PropertiesService.getScriptProperties();
  let apikey = prop.getProperty("apikey");

  const options = {
    method: 'post',
    headers: {
      Authorization: "Bearer " + apikey,
      'Content-Type': 'application/json',
    },
    muteHttpExceptions: true,
    payload,
  };

  //URLリクエスト
  const response = UrlFetchApp.fetch(endpoint, options);

  //レスポンスを取得
  const content = response.getContentText();
  const json = JSON.parse(content);

  //値を返す
  if (json.error) {
    return "OpenAI APIエラー: " + json.error.message;
  }
  return json;
}

//画像をDriveにダウンロードする
function generateImageToDrive(endpoint, keywords) {
  //URLをリクエスト
  const response = UrlFetchApp.fetch(endpoint);

  //出力先指定
  let prop = PropertiesService.getScriptProperties();
  let folderid = prop.getProperty("folderid");

  //Blobで取得してドライブに画像を生成
  const blob = response.getBlob();
  const file = DriveApp.getFolderById(folderid).createFile(blob).setName(keywords + ".png");

  //ファイルのIDを返す
  return file.getId();
}

実際に利用してみる

スライドを開くと上部のメニューに「GPTスライダー」という項目が出てきます。それぞれクリックするとダイアログが出てきて、内容を入力すると動作するという仕組みです。

  • スライド作成開始:ダイアログ入力値にしたがって「栽培方法」に関するスライドをドカっと作成します。また、コード内でimagenflgがTrueの場合はそれぞれのスライド内にテーマに沿った画像を生成し埋め込みます。
  • 画像生成開始:これは現在アクティブになって開いてるスライドに対して1枚だけ画像を生成する機能です。ダイアログ入力値に従って生成し、スライド左側に400x300くらいのサイズにリサイズして表示(デフォルトでは中身は1024x1024のサイズです)。

スライド作成では、生成されたスライドを下地にして、ここから人間が手を加えていくスタイルです。プロンプトを改造すればより詳細なものも作れるでしょうが、人間がクリエイティブな領域まで捨てたら、つまらない量産的なスライドしか作れません。

故に自分の場合、半自動なこういったスタイルで、下地だけ作ってもらって自分がそこに手を加えていくスタイルでプログラムを作っていますし、現在の生成AIもチャットでどうこうではなく、ある程度決まったものはチャットを使うようなスタイルではなく通常のREST APIのようにAIを意識させないで使っていくスタイルに収斂していくと思われます(チャットで細かく指示とか面倒だし、Alexaもそうですがそういうスタイルは普及しない)。

図:ダイアログで最低限の内容だけ入力する

図:プログラムがプロンプトを追加して仕上げる

動画生成AI関係

そうなってくるとスライドに挿入する簡単な動画なども欲しくなってくるところ。現在のところ、動画生成AIとしてすでに登場してるものやこれから登場するものをリストアップしました。

SoraかVeoが公開されたら、このスクリプトに装備を追加してみようと思う。

関連資料

動画

How to integrate ChatGPT API with Google Slides - AI Presentation

How to Use ChatGPT in Google Sheets with Apps Script?

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)