🌊

AIを使ったモックデータ生成スクリプト(画像込み)

2024/12/12に公開


アプリのモックデータを生成するには、比較的時間がかかります。AIチャットボットを使ったとしても、プロンプトを書いたり、結果をコピーしたり、DBに入れたりする必要があったりするので、地味に大変です。自分は最近この問題に直面し、手動での作業は避けたかったので、OpenAIのAPIを使って自動化を試みました。以下でその方法を紹介します。

スクリプトの特徴

  • ⭐ JSON形式での構造的な出力: OpenAIのgpt-4o-miniモデルを使って構造化されたJSON出力により、モックデータを生成します
  • ⭐ バッチ大小の設定: データ生成の速度を調節できます
  • ⭐ 画像生成: dall-e-3を使用してアイテムの現実的な画像を生成します
  • ⭐ 画像最適化: Sharpを使って画像をWebP形式に圧縮し、パフォーマンスを向上させます
  • ⭐ 画像アップロード: 最適化された画像をAWS S3バケットに保存します
  • ⭐ データベース組み込み: 生成されたデータを簡単にデータベースに組み込むことができます
  • ⭐ 再利用可能なデザイン: さまざまなモックデータの生成において、容易に適用できます

TypeScriptスクリプトの単独実行

最初に必要なのは、アプリ内ですべての環境変数にアクセスできるスクリプトを実行する方法です。今回はtsxdotenv-cliを使用しました。

これらをインストールするためには次のように入力します:

npm i -D tsx dotenv-cli

これで、簡単なスクリプトを作成できます:

(async () => {
  console.log("TEST");
})();

これを実行するには次のように入力します:

npx dotenv -e .env tsx ./src/scripts/test.ts

モックデータの生成

特定のフィールドを含むレシピリストをgpt-4o-miniモデルのJSON形式で生成し、それをデータベースに追加する方法を試しました。複数の生成を並列で実行してみたところ、重複したレシピが生成されてしまいました。この問題を解決するため、生成を直列処理に変更し、既存のレシピ情報をプロンプトに組み込むことで重複を最小限に抑えました。重複を避けつつ並列生成を実現する方法も検討可能ですが、今回は直列処理でも目的を達成できました。

import { db } from "@/server/db";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

// OpenAIから返されるレシピデータのスキーマ
const recipeSchema = z.object({
  recipes: z.array(
    z.object({
      name: z.string(),
      description: z.string(),
      ingredients: z.array(z.string()),
      steps: z.array(
        z.object({
          instruction: z.string(),
        })
      ),
      duration: z.number(),
      servings: z.number(),
    })
  ),
});

type Recipe = z.infer<typeof recipeSchema>["recipes"][number] & {
  image?: string; // これは後で追加されます
};

async function generateRecipes(count: number, allRecipeNamesStr: string) {
  const prompt = `Generate ${count} unique and diverse recipes that are different from the following recipes: ${allRecipeNamesStr}.`;

  // OpenAIのAPIを使用してレシピの一覧をJSON形式で生成します
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: prompt }],
    response_format: zodResponseFormat(recipeSchema, "recipes"),
  });

  const responseContent = completion.choices[0]?.message?.content ?? "";
  return recipeSchema.parse(JSON.parse(responseContent)).recipes;
}

画像の生成

画像を生成するために、dall-e-3モデルを使用しました。1枚あたりのコストは$0.04です。コストを半分に抑えたい場合はdall-e-2を使用することも可能ですが、画像の品質は劣ります。

async function generateRecipeImage(recipe: Recipe) {
  console.log(`Generating image for ${recipe.name}`);

  const imagePrompt = `An appetizing photo of the dish: ${recipe.name}`;

  const res = await openai.images.generate({
    model: "dall-e-3",
    prompt: imagePrompt,
    size: "1024x1024",
  });

  const url = res.data[0]?.url;
  if (!url) {
    throw new Error("No image url found");
  }
  return url;
}

画像の最適化

先ほどdall-eで生成した画像はかなり容量が大きいため、Sharpを使ってWebP形式に変換します。これによりサイズが約1.7MBから170KBに減少しましたが、品質に目立った損失はありませんでした。

import sharp from "sharp";

async function optimizeImage(imageUrl: string) {
  const response = await fetch(imageUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.statusText}`);
  }

  const arrayBuffer = await response.arrayBuffer();

  const optimizedBuffer = await sharp(Buffer.from(arrayBuffer))
    .webp() // デフォルトの品質は80
    .toBuffer();

  return optimizedBuffer;
}

画像のアップロード

dall-eから生成された画像は有効期限付きのURLにホストされているため、自分のストレージにアップロードする必要があります。今回はAWS S3バケットに保存します。

  • AWSプロジェクトキーを環境変数に設定します:
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_REGION=ap-northeast-1
  • アップロード関数を実装:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION });

async function uploadImage(buffer: Buffer) {
  const bucketName = "your-bucket-name";
  const key = `recipes/${Date.now()}.webp`;
  
  await s3.send(
    new PutObjectCommand({
      Bucket: bucketName,
      Key: key,
      Body: buffer,
      ContentType: "image/webp",
    })
  );
  // 普段はCloudFrontを設定した方がいいですが、このサンプルはS3のURLにしています。
  return `https://${bucketName}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
}

データベースへのデータ挿入

すべてのデータと画像の準備が整ったら、それらをデータベースに挿入します。今回はDrizzleSupabaseのPostgresを使いました。

async function insertRecipesIntoDB(recipes: Recipe[]) {
  await db
    .insert(tRecipe)
    .values(
      recipes.map((recipe) => ({
        name: recipe.name,
        description: recipe.description,
        ingredients: recipe.ingredients,
        steps: recipe.steps,
        duration: recipe.duration,
        servings: recipe.servings,
        image: recipe.image,
      }))
    )
    .execute();
}

コストの考慮

画像生成には1枚あたり$0.04(dall-e-2を使用する場合は$0.02)がかかり、例えば30件のアイテムを生成する場合、約$1.20のコストがかかります。一方でテキスト生成のコストは、画像生成に比べて非常に小さいため、特段考慮する必要性はありません。

完成コード

以下がこれまで紹介した内容全体をまとめた完全なコードです。

import { db } from "@/server/db";
import { tRecipe } from "@/server/db/schema";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";

const openai = new OpenAI();
const s3 = new S3Client({ region: process.env.AWS_REGION });

// OpenAIから返されるレシピデータのスキーマ
const recipeSchema = z.object({
  recipes: z.array(
    z.object({
      name: z.string(),
      description: z.string(),
      ingredients: z.array(z.string()),
      steps: z.array(
        z.object({
          instruction: z.string(),
        })
      ),
      duration: z.number(),
      servings: z.number(),
    })
  ),
});

type Recipe = z.infer<typeof recipeSchema>["recipes"][number] & {
  image?: string; // あとから画像フィールドが追加されます
};

(async () => {
  const totalRecipes = 20; // 生成するレシピの総数
  const recipesPerBatch = 10; // バッチごとに生成するレシピの数
  const totalBatches = Math.ceil(totalRecipes / recipesPerBatch); // バッチの総数を計算

  // 重複を避けるために、データベースから既存のレシピ名を取得
  const allRecipeNames = (await db.query.tRecipe.findMany().execute()).map(
    (recipe) => recipe.name
  );

  for (let batchNumber = 1; batchNumber <= totalBatches; batchNumber++) {
    console.log(`Generating recipes batch ${batchNumber}/${totalBatches}`);

    // ユニークなレシピのバッチを生成
    const recipes = await generateRecipes(
      recipesPerBatch,
      allRecipeNames.join(", ")
    );

    // 各レシピの画像を生成
    await generateImagesForRecipes(recipes);

    // 新しいレシピをデータベースに挿入
    await insertRecipesIntoDB(recipes);

    // 追加された新しいレシピ名で全レシピ名リストを更新
    allRecipeNames.push(...recipes.map((recipe) => recipe.name));
  }
})();

async function generateRecipes(count: number, allRecipeNamesStr: string) {
  const prompt = `Generate ${count} unique and diverse recipes that are different from the following recipes: ${allRecipeNamesStr}.`;

  // OpenAIのAPIを使用してレシピの一覧をJSON形式で生成
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: prompt }],
    response_format: zodResponseFormat(recipeSchema, "recipes"),
  });

  // AIの応答からコンテンツを抽出
  const responseContent = completion.choices[0]?.message?.content ?? "";

  // 定義されたスキーマを使用して応答を解析および検証
  const generatedRecipes = recipeSchema.parse(
    JSON.parse(responseContent)
  ).recipes;

  return generatedRecipes;
}

async function generateImagesForRecipes(recipes: Recipe[]) {
  const batchSize = 5; // 同時に生成する画像の数
  for (let i = 0; i < recipes.length; i += batchSize) {
    const batch = recipes.slice(i, i + batchSize);
    // 現在のバッチの画像を同時に生成
    await Promise.all(
      batch.map(async (recipe) => {
        try {
          const imageUrl = await generateRecipeImage(recipe);
          recipe.image = imageUrl; // レシピに画像URLを追加
        } catch (error) {
          console.error(`Failed to generate image for ${recipe.name}:`, error);
        }
      })
    );

    console.log(`Waiting before the next batch...`);
    // tier1レート制限は1分あたり5リクエスト😢
    // 高いティアがあればこの遅延を取り除けるかもしれません
    await new Promise((resolve) => setTimeout(resolve, 50000)); // 50秒待機
  }
}

async function generateRecipeImage(recipe: Recipe) {
  console.log(`Generating image for ${recipe.name}`);

  const imagePrompt = `An appetizing photo of the dish: ${recipe.name}`;

  // OpenAIの画像生成APIをプロンプトと共に呼び出す
  const res = await openai.images.generate({
    model: "dall-e-3",
    prompt: imagePrompt,
    size: "1024x1024",
  });

  // 応答から画像のURLを抽出
  const url = res.data[0]?.url;
  if (!url) {
    throw new Error("No image url found");
  }

  // アップロード前に画像を最適化
  const buffer = await optimizeImage(url);

  // 最適化された画像をS3にアップロード
  const imageUrl = await uploadImage(buffer);
  return imageUrl;
}

async function optimizeImage(imageUrl: string) {
  // 指定されたURLから画像を取得
  const response = await fetch(imageUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.statusText}`);
  }

  // 応答を処理するためにArrayBufferに変換
  const arrayBuffer = await response.arrayBuffer();

  // sharpを使って画像をWebP形式に変換
  const optimizedBuffer = await sharp(Buffer.from(arrayBuffer))
    .webp() // デフォルトの品質は80
    .toBuffer();

  return optimizedBuffer;
}

async function uploadImage(buffer: Buffer) {
  const bucketName = "your-bucket-name";
  const key = `recipes/${Date.now()}.webp`;

  await s3.send(
    new PutObjectCommand({
      Bucket: bucketName,
      Key: key,
      Body: buffer,
      ContentType: "image/webp",
    })
  );
  // 普段はCloudFrontを設定した方がいいですが、このサンプルはS3のURLにしています。
  return `https://${bucketName}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
}

async function insertRecipesIntoDB(recipes: Recipe[]) {
  await db
    .insert(tRecipe)
    .values(
      recipes.map((recipe) => ({
        name: recipe.name,
        description: recipe.description,
        ingredients: recipe.ingredients,
        steps: recipe.steps,
        duration: recipe.duration,
        servings: recipe.servings,
        image: recipe.image,
      }))
    )
    .execute();
}

結論

AIを使ってモックデータの作成を自動化することで、手動でデータを作成・管理するよりも大幅に時間を節約できます。この方法は、少量のデータから何百個ものデータまで、柔軟に対応可能です。さらに、リアルなデータを使うことでアプリの完成度も高まり、見た目の印象も良くなります。最初に少し時間を投資する必要はありますが、一度ロジックを作成しておけば次回以降はより迅速に対応できます。大量のモックデータが必要な場合は、このアプローチをぜひ試してみてください。

SUPER STUDIOテックブログ

Discussion