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

以前別のエントリーにてGemini for Google Workspaceに装備されていない「スライド一式を自動生成」をGASで実現するテクニックを作成しましたが、今回、Gemini API + Imagen3で同様のものを作成してみました。

Gemini APIにてスライドを作成し、Imagen3で画像を生成して挿入するという形でリメイクしてみました。既に現時点Gemini AdvancedではImagen3を使った画像生成がリリース済みです。

図:芋の画像を生成してみた

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

今回利用するファイル等

今回のスクリプトはスライド生成についてはGemini APIを利用します。APIはAPIキーの利用で問題ないですが、画像生成を担当するImagen3はVertex AI APIであるため、Google Cloud側とGASのプロジェクトを連結する必要があります。基本的な流れは以前のBard時代のリクエスト方法と同じです。

Google Apps ScriptからGemini API?で質問してみた【GAS】

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

事前準備

スライドの準備

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

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

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

Gemini のAPIキーを取得する

GeminiのAPIキーが必要です。以下のエントリーに独立してまとめています。課金されますので利用のしすぎには要注意。また課金されていない場合、学習に利用されてしまう恐れがあるので、きちんとGoogle Cloud上で課金アカウントとの紐付けなどをしておきましょう。

GeminiのAPIキーの取得と学習の可否

APIを有効化

GCP側で今回利用するAPIを有効化する必要性があります。

  1. GCPを開き、サイドバーからAPIとサービスを開きます。
  2. 上部にある「APIとサービスの有効化」をクリック
  3. 「Vertex」を検索し、Vertex AI APIクリックします。
  4. 有効化をクリックします。

図:今回はこのAPIを有効化する

プロジェクトの連結

Google Apps ScriptのプロジェクトをGCP側と連結する手順は以下の通り

  1. Google Cloud Consoleを開く
  2. 左上にある▼をクリックする
  3. ダイアログが出てくるので、新規プロジェクトを作るか?既存のプロジェクトを選択する。この時、Google Workspaceであれば選択元は「自分のドメイン」を選択する必要があります。
  4. プロジェクト情報パネルから「プロジェクト番号」をコピーする
  5. 同じくプロジェクトIDもコピーしておく(こちらはコード内で利用します)
  6. 対象のGoogle Apps Scriptのスクリプトエディタを開く
  7. サイドバーからプロジェクト設定を開く
  8. プロジェクトを変更ボタンをクリック
  9. GCPのプロジェクト番号に、4.の番号を入れてプロジェクトを設定をクリック

図:プロジェクト番号をコピーしておきます

図:プロジェクト変更画面

Google Cloud Consoleを弄ってみる

appsscript.jsonに記述を追加する

スクリプトエディタの左サイドバーから「プロジェクト設定」を開き、「appsscript.json」マニフェスト ファイルをエディタで表示するにチェックを入れて、appsscript.jsonを表示する。その後そのファイルを開き、以下のように記述を行います。必須の作業です。これをしてしまうと、今後メソッドを追加したときに追加の認証は手動で、Scopeを入れてあげないと認証されないので要注意。

"oauthScopes": [
    "https://www.googleapis.com/auth/cloud-platform",
    "https://www.googleapis.com/auth/userinfo.profile",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/presentations"
],

これを入れてあげないと403などのエラーになってしまうので要注意。GCPのAPIを叩く場合には必須の作業です。

スクリプトプロパティに値を格納

前述までに取得しておいたGeminiのAPIキーおよび画像ファイルの出力先フォルダとしてスクリプトプロパティに値をセットします。

  • folderid:出力先フォルダのID
  • geminikey : Gemini APIのAPIキー

図:2つ値をセットしておく

初回認証をしておく

ここまでの準備で適当な関数を用意して実行すると認証が始まります。これをやっておかないと、スコープが足らないであったり、コードが初回で旨く動かないことが稀にあるので必ず実行しておきましょう。

ソースコード

スライド生成

概要

前回の記事同様にZennにて同じことを実現してる方がいるので、そちらのソースコードをベースにしつつ、ChatGPTとは少々出力パターンが異なるので調整を入れています。

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

生成プロンプト

今回は前回同様に「植物の栽培法」に関するスライドを自動生成するようにしてるので、ダイアログボックスでは植物の名前を入れてOKを押すだけで一気に生成するようにしています。故にプロンプトは以下のようなスタイルにしています。

但し、ChatGPTと異なりかなり細かく指示しないと期待してるスライドが出てこないので、かなり手を入れています。

let prompttext = str + "の簡単な説明と栽培方法についてのGoogleのスライドを作成したいです。ファイルのタイトルや各スライドのタイトル、その中の詳細な項目を生成してください。但し以下の条件を守って生成してください。\n"
                     + "・回答にはマークダウンを使って返してください"
                     + "・マークダウンは、スライドタイトルは#で、各スライドの項目は##、その中の詳細な内容は###、詳細にぶら下がる内容は「- 」とし、たくさん列挙しないでください。"
                     + "・各スライドの中で特に、「" + str + "の栽培方法」のスライドは1枚にまとめず、個々のスライドに分割してください。"
                     + "・水やりや肥料、土の内容、料理方法等についても加えてください。"
                     + "・「以上が」といった余計なコメントは説明は不要です"
                     + "・回答には「---」や「```」といった文字は含めないでください"
  • strにはダイアログの入力欄の植物の名前が入ってきます。
  • 値はマークダウン形式で受け取ります
  • 応用事例として料理法についても入れるように追加の指示を出しています。
  • 生成内容にありがちな「余計なコメント」についても出力しないように指示を出しています。
  • またこれもありがちですが、「---」や「`」というスライドデータ内容の上下に出てくる余計なワードも除外しています。
  • マークダウンの出し方が曖昧であったので細かい指示を追加。結果、3階層目も出てくるようになったので、途中のセクション切れ目のスライドマスターを追加しています。

コード

基本スタイルは前回記事のChatGPTで生成のパターンと同じ。相違点としてChatGPTの場合スライド階層が2段階目までだったのが、3段階目も出るようになってるため、マークダウンが###の場合の処理に2段階目を移動させて、新たに2段階目はセクション区切りのスライドを1枚入れるように調整しています。

また各スライドのtopの値は固定値としています。

//Gemini API リクエストエンドポイント
const apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=';

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

//スライド作成で呼び出される関数
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("スライド生成完了")
}

//対象のIDを持つシートのデータを元に栽培法をGeminiに聞いてみる
function createSlideFile(str){
  //geminiのapi keyを取得する
  let prop = PropertiesService.getScriptProperties();
  let apikey =prop.getProperty("geminikey");
  let folderid = prop.getProperty("folderid");

  //プロンプトを作成する
  let prompttext = str + "の簡単な説明と栽培方法についてのGoogleのスライドを作成したいです。ファイルのタイトルや各スライドのタイトル、その中の詳細な項目を生成してください。但し以下の条件を守って生成してください。\n"
                              + "・回答にはマークダウンを使って返してください"
                              + "・マークダウンは、スライドタイトルは#で、各スライドの項目は##、その中の詳細な内容は###、詳細にぶら下がる内容は「- 」とし、たくさん列挙しないでください。"
                              + "・各スライドの中で特に、「" + str + "の栽培方法」のスライドは1枚にまとめず、個々のスライドに分割してください。"
                              + "・水やりや肥料、土の内容、料理方法等についても加えてください。"
                              + "・「以上が」といった余計なコメントは説明は不要です"
                              + "・回答には「---」や「```」といった文字は含めないでください"
 
  //レコードが見つかった場合にはGeminiにリクエストする
  // リクエストボディを作成する。
  var body = {
    "contents": [
      {
        "parts": [
          {
            "text": prompttext
          }
        ]
      }
    ],
    "generationConfig": {
      "temperature": 0.5,
      "topK": 50,
      "topP": 0.2,
      "maxOutputTokens": 4096,
      "stopSequences": []
    }
  };
 
 
  // リクエストを送信する。(Gemini 1.5 Flashを使用)
  var response = UrlFetchApp.fetch(apiUrl + apikey, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(body)
  });
 
  // レスポンスをパースする。
  var responseJson = JSON.parse(response.getContentText());
 
  // 生成されたテキストを返す。
  let res = responseJson.candidates[0].content.parts[0].text;

  //現在のスライドを取得
  let presen = SlidesApp.getActivePresentation();
  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 = [];
  let subsection = false;

  slideTexts.forEach((slideText, index) => {
    //改行コードで分割する
    const elements = slideText.split('\n');
 
    //処理用の各種配列
    let textBox;
    let slide;
    let title;
  
    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(200).setWidth(500).setLeft(50);
            textBox.getText().getTextStyle().setFontSize(35).setBold(true);
          break;
 
        //各スライドタイトル
        case element.startsWith('## '):
          // 新しいスライドを追加
          slide = presen.appendSlide(layouts[6]);

          //タイトルの挿入と装飾
            title = element.substr(2);
            textBox = slide.insertTextBox(title).setTop(40).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(40).setWidth(500).setLeft(50);
          textBox.getText().getTextStyle().setFontSize(24).setBold(true);
 
          //生成結果を格納する
          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(80).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);
          }
 
          break;
          
        //その他の文字種
        default:
          //それ以外の文字はテキストボックスで追加
          //textBox = slide.insertTextBox(element).setTop(yOffset).setWidth(500).setLeft(50);
          //textBox.getText().getTextStyle().setFontSize(14);
          break;
      }
    });
  });
  return array;
}

//連続で画像を生成する(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 imagen3Generator(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;
  } ) ;
}

画像生成

概要

ChatGPTと異なり、GeminiのAPIキーを持ってして画像の生成が現在出来ません。よって、Vertex AI APIのImagen3を使っている為、認証TokenはGASの認証時のものを利用するようにしています(ScripApp.getOAuthToken()がそれに該当)。

ChatGPT APIのようにImageSizeに関するプロパティが見つからなかったのでデフォルトのまま利用しています。

ChatGPTと異なりどちらかというとリアルな絵を生成するのが得意である一方、あるアニメや主人公の名前を入れてみたら「確かにそれっぽいキャラクタ画像」が出てきたり、トーマスと入れたら機関車トーマス風の額にシワが寄ったものが出てくる点など、ちょっと版権的に際どい感じがします。

割とエッジがはっきりしたものが多い印象です。生成AI特有のモヤっと感があまり無い。

プロジェクトIDを入力する

Gemini APIはAPIキーを利用して生成が可能ですが、Imagen3は別のAPIである為、GeminiのAPIキーではなく自身のGCPのテナントを利用して生成しています。よってコードの中でprojectidという変数に自分のテナントのプロジェクトIDを入れる必要があります。

前述でGASプロジェクトをGCPに紐つけましたが、同じ画面にあるプロジェクトIDのほうをコードの中に記述します。これもスクリプトプロパティに入れて利用するほうが良いかも。

図:プロジェクトIDが必要になります。

生成プロンプト

今回はシンプルに「入力値に関する画像を生成」とだけ指定しています。ただこれだけだと指示が直球すぎるのと、Dall-e-3とは大分出力感が異なるので、調整は必要かもしれない。

特にスライド生成時に呼び出す場合にはプロンプトは「スライドのテーマ + 各スライドのタイトル」を含めて、上記のプロンプトで生成してるので、何らかのジャンル選択などのサブ項目は必要かもしれないと思います。

ちなみに英語じゃなく日本語でプロンプトを投げていますが問題なく出力は出来ています。

コード

モデルは複数あり、また古いImagen2なども生きていたりするため、Imagen3は「imagen-3.0-generate-001」を2024年11月時点では利用する点に注意。ドキュメントも違う可能性があります。

  • エンドポイントに自身のプロジェクトIDやRegion指定が可能となっています。
  • 速度を求める場合には、「imagen-3.0-fast-generate-001」を利用しましょう。ImageSizeの指定がなく、ImangeCountとLanguage指定があるのでJapaneseを指定してる為、日本語プロンプトはきちんと通ります。
  • 出力結果は画像のURLではなく何故かBase64エンコードされた文字列なので、GASでデコードしてDriveにファイル生成をするコードにしています(画像形式はpngになっています)。

ChatGPTなどの画像生成AIからするとやはり出遅れ感は感じる

図:どこかで見たようなタッチの絵

図:機関車やとうもろこし畑は割とリアル

//画像生成エンドポイント
//imagen-3.0-fast-generate-001というモデルもある(高速生成版)
//imagegeneration@005は古いImagen2のモデル
const projectid = "自身のGCPプロジェクトIDを入れる";
const imageModel = "imagen-3.0-generate-001";
const imageCount = 1  //1〜4まで
const imagepoint = "https://us-central1-aiplatform.googleapis.com/v1/projects/" + projectid + "/locations/us-central1/publishers/google/models/" + imageModel + ":predict";

//Imagen3で画像生成して取得する
function makeGeminiImage() {
  //入力プロンプトを表示
  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 = imagen3Generator(keywords);
  
  //生成された画像のblobを取得
  let blob = DriveApp.getFileById(imageId).getBlob();
 
  //現在のスライドに追加する
  let slide = SlidesApp.getActivePresentation().getSelection().getCurrentPage();
  slide.insertImage(blob).setWidth(300).setHeight(300).setLeft(350).setTop(80);
 
  //終了処理
  ui.alert("終了しました。")
}


//Imagen3で画像生成する
function imagen3Generator(keyword){
  //出力先フォルダを取得
  let prop = PropertiesService.getScriptProperties();
  let targetfolder = prop.getProperty("folderid");

  //プロンプトをセット
  let prompt = keyword + "に関する画像を生成";

  //リクエストボディを用意する
  let payload = {
    "instances": [
      {
        "prompt": prompt
      }
    ],
    "parameters": {
      "sampleCount": imageCount,
      "language": "ja"
    }
  }

  //送信オプション
  //APIキーではなくScriptApp.getOAuthTokenを利用する
  const options = {
    method: 'post',
    headers: {
      Authorization: "Bearer " + ScriptApp.getOAuthToken(),
      'Content-Type': 'application/json; charset=utf-8',
    },
    muteHttpExceptions: true,
    payload:JSON.stringify(payload)
  };

  //URLリクエスト
  const response = UrlFetchApp.fetch(imagepoint, options);
 
  //レスポンスを取得
  const content = response.getContentText();
  const json = JSON.parse(content);

  //Base64で返ってくるのでデコードする(1個目のみ)
  let base64 = json.predictions[0].bytesBase64Encoded;
  let decoded = Utilities.base64Decode(base64);

  //Blobとして取得
  let blob = Utilities.newBlob(decoded, "image/png", keyword + ".png");

  //ドライブに生成する
  let folder = DriveApp.getFolderById(targetfolder);
  let fileid = folder.createFile(blob).getId();

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

実際に利用してみる

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

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

スライド作成では、生成されたスライドを下地にして、ここから人間が手を加えていくスタイルなのは前回同様です。ただ、小松菜で生成してみたら謎のキャラクターが生成されました。日本語の扱いがあまり良くないようです。

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

図:トマトを生成してみた

関連リンク

コメントを残す

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

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