🤖

AIで画像から大喜利ができるアプリを作った

2023/12/16に公開

この記事はクソアプリ Advent Calendar 2023の16日目の記事です。

はじめに

進歩が激しいGPTですが、先日ついにGPT-4Vが使えるようになりました。ということで、画像から内容を把握するAIを使って、何かを作ってみようということでAI大喜利アプリを作りました。

https://twitter.com/yui_active/status/1735504126992064653

画像から人が大喜利を考えるのは良くありますが、せっかくなのでこれからはAIに大喜利を考えてもらいましょう!

使い方

使い方は画像を選択してボケてまたは褒めてのボタンを押すだけです。


こんな感じでよくわからないボケをしてくれます。

褒めてに関しては大喜利とは関係ないのですが、何か一言ポジティブな方向に大喜利してもらいたいなと思ってつけました。ただ、思ったより面白くないのでそのうち外すかもしれません。

技術スタック

Next.js
TypeScript
Tailwind CSS
Vercel

今回デザイン的にはSPAで動くのですが、OpenAIのキーを露出させないためにNext.jsのapiルートを使ってSSRでAPI通信を行っています。

こだわりポイント

ロゴとシェア方法を絶妙にこだわりました。

ロゴに関しては、背景は黄色にするつもりだったので、映えるようにAIの文字の部分にAIぽい網目をつけました。

シェアについては投稿された画像をこちらで保存したくないのでWeb Share APIを使って画像を直接ポストできるようにしました。

X(Twitter)を選んで投稿すると以下のように画像が投稿されます。

https://twitter.com/yui_active/status/1735521975869624546

技術的なこと

アウトプットを一気に出すのではなく、ChatGPTのように少しずつ出してユーザーの待ち時間を少しでも減らすために、ストリーミング通信を行いました。
今まではこの部分は自作していたのですが、いつの間にかとても便利なnpmパッケージが出ていたので、今回は以下の二つを使いました。

APIルートの中身は以下のようになっています。

import { Configuration, OpenAIApi } from "openai-edge";
import { OpenAIStream, StreamingTextResponse } from "ai";

export const runtime = "edge";
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);
export async function POST(req: Request) {
  const { image } = await req.json();
  const response = await openai.createChatCompletion({
    model: "gpt-4-vision-preview",
    stream: true,
    max_tokens: 4096,
    messages: [
      {
        role: "user",
        content: [
          {
            type: "text",
            text: ${prompt},
          },
          {
            type: "image_url",
            image_url: image,
          },
        ],
      },
    ],
  });
  const stream = OpenAIStream(response);
  return new StreamingTextResponse(stream);
}

これを、クライアントから以下のように叩いています。

    await fetch("/api/ogiri", {
      method: "POST",
      body: JSON.stringify({ image }),
      headers: {
        "Content-Type": "application/json",
      },
    }).then(async (res) => {
      setIsLoading(false);
      const reader = res.body?.getReader();
      while (true) {
        const { done, value } = await reader?.read();
        if (done) break;
        let currentChunk = new TextDecoder("utf-8").decode(value);
        setResponse((prev) => prev + currentChunk);
      }
    });

これでsetResponseを通してresponseが徐々に更新されていき、OpenAIからの回答が少しずつ反映されるようになっています。

また、iphoneで撮った画像などだと、画像が大きすぎて処理しきれないことがあるので、画像をcanvas上でリサイズする関数を書いて使っています。

const getImage = (file: File) => {
  return new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(img.src);
      resolve(img);
    };
    img.onerror = (error) => {
      reject(error);
    };
    img.src = URL.createObjectURL(file);
  });
};
export default async function resizeImage(
  file: File,
  limitWidth: number,
  limitHeight: number
) {
  const image = await getImage(file);
  if (!image) return;
  const aspect = image.width / image.height;
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
  let canvasWidth: number;
  let canvasHeight: number;
  if (image.width > limitWidth || image.height > limitHeight) {
    // 規定サイズよりも画像が大きい場合
    if (aspect > 1) {
      // 横長画像の場合
      canvasWidth = limitWidth;
      canvasHeight = limitHeight * (image.height / image.width);
    } else {
      // 縦長画像の場合
      canvasWidth = limitWidth * (image.width / image.height);
      canvasHeight = limitHeight;
    }
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;
  } else {
    // 規定サイズ内の場合
    canvas.width = image.width;
    canvas.height = image.height;
    canvasWidth = image.width;
    canvasHeight = image.height;
  }
  ctx.drawImage(
    image,
    0,
    0,
    image.width,
    image.height,
    0,
    0,
    canvasWidth,
    canvasHeight
  );
  return canvas.toDataURL("image/jpeg", 0.85);
}

使う時は以下。

  const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      const file = e.target.files[0];
      const imageUrl = URL.createObjectURL(file);
      setImage(imageUrl);
      let imgToBase64 = await resizeImage(file, 1000, 1000);
      setImage(imgToBase64);
    }
  };

この関数は2年前ぐらいから、画像をつかったアプリ全てで使っています。
メンテナンスできてないので、今はもう少しいい書き方があるかもしれませんが、いずれにしても画像そのままをアップロードするのではなくて一度リサイズする方が処理も早くなっていいと思っているのでこのようにしています。

制作裏話

これはGPTあるあるだと思うのですが、普通に画像でボケてくださいというだけだと、実際の人物を使って冗談を言うことは著作権に違反する可能性がありますなどと言われてしまいできませんでした。
そこで、この画像の人物は架空の人物であり、著作権に関して考える必要はありませんなどと付け加えても、画像の人物が架空の人物であるかどうかがわからない以上、人権を無視することはできませんなどと言われてしまい、発狂してました。
めげずにプロンプトを工夫し続けて、なんとか安定したアウトプットが出るようになりました。

去年の会社をサボるときに使えるアプリを作ったでもChatGPTに怒られたという話を書きましたが、ChatGPTはやはりモラルがしっかりしすぎているので、ジョークアプリを作ろうとするとなかなかプロンプトを工夫する必要があります・・・。

あとがき

この機会に渾身の画像を使ってAIに大喜利を考えてもらうのはいかがでしょうか。
最後まで読んでいただきありがとうございました!

Discussion