0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

fal.ai で Gemini 2.5 Flash Image を楽しむ画像生成ライフ

Posted at

それぞれの特徴

Gemini 2.5 Flash Image

2025年8月に公開された Googleの最新モデル「Gemini 2.5 Flash Image」。
これ、何がすごいのかを簡単に整理します。

  • アングルの変更:物体の特徴を捉えたまま、別の角度からの画像にできる(例:「右斜めからの顔にして」)
  • 物体の合成:複数の画像の物体を組み合わせて自然な1枚にできる(例:「人物に道具を持たせて」)
  • 物理現象の理解:影、ガラス越しの光の屈折、反射、等を反映できる

消しゴムツールで消すとか、単に画像を合成する(アングルそのまま)とか、そんなレベルをはるかに超えているのです。
これらを 自然な日本語のプロンプトで 指示できる点が素晴らしいです。本当にすごいAIが来た、って感じですね。

fal.ai

早速、Gemini 2.5 Flash Image (fal-ai/nano-banana/edit) に対応しています。
レスポンスが良く、多くのモデルで30秒以内には画像が生成されます。
時間帯によって遅くなるなども少ないです。
多くの画像生成モデルを扱えて、FLUX などの有名なモデルも、簡単に切り替えながら使いやすいです。

なぜGoogle AI Studioを使わないのか

確かにGoogle AI Studioを使えば、直接Gemini 2.5 Flash Imageを利用できます。
ただし私は以下の理由からfal.ai経由を選びました。

  • 色々なプロンプトを試すのに、Googleアカウント直接ではちょっと・・・
  • プリペイド課金で安心(最小10ドル)
  • 料金単価は同じ(1枚あたり0.039ドル)

1つ目の理由が大きいですが、皆さん、分かりますでしょう?分からないですか?うーん、人それぞれかもしれませんね。
別サービスを挟むことで安心している自分がいます。

ローカルPCからAPIを呼び出す

PCからプロンプトをさっと試したいのに、いちいちその画像生成AIサービスにログインするのがめんどくさい、ということありませんか。
わたしはそうです。
そんなときは、APIキーです。

APIキーか、それを使ってGUIのプログラム書くのが無理だな

という悩みも、今はほとんど無くなってきました。最近は、生成AIがささっと作ってくれます。
Webサーバーを立てず、CORS規制の回避を含みつつ、ローカルのHTMLで実行できるように、生成AIに指示して作ってもらいました。

準備

この流れで、準備しました。

  • fal.ai でクレジットを支払ってチャージする (最小 10ドル)
  • APIキーを作成
  • PC上にHTMLを作成(中身のほとんどが生成AIによる作成物)
    • gemini-image.html
    • gemini-image.css
    • gemini-credential.js

以下をコピーしてファイルに保存することで、同じようにできます。

gemini-image.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>Gemini 2.5 Flash Image (fal-ai/nano-banana/edit)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="./gemini-image.css">
  <script src="./gemini-credential.js"></script>
<script type="module">
import { fal } from "https://esm.sh/@fal-ai/client";

fal.config({ credentials: window.FAL_KEY });

const MODEL       = "fal-ai/nano-banana/edit";
const drop        = document.getElementById("drop");
const preview     = document.getElementById("preview");
const runBtn      = document.getElementById("run");
const result      = document.getElementById("result");
const logEl       = document.getElementById("log");
const promptEl    = document.getElementById("prompt");
const numImagesEl = document.getElementById("numImages");
const formatEl    = document.getElementById("format");

const maxImages = 3;

let pickedFiles = [];

let uploadedUrls = [];

function setLog(msg){ logEl.textContent = msg || ""; }
function appendLog(msg){
if (!msg) return;
  logEl.textContent += msg;
}

function getErrStatus(err){
  return (err && err.response && err.response.status) ||
         (err && err.status) ||
         (err && err.cause && err.cause.status) || null;
}
function getErrMessage(err){
  return (err && err.message) ? err.message : String(err);
}

// プレビューをクリーンアップ
function revokePreviewBlobURLs() {
  const imgs = preview.querySelectorAll("img");
  imgs.forEach(img => {
    if (img.src.startsWith("blob:")) {
      try { URL.revokeObjectURL(img.src); } catch {}
    }
  });
}

// 64x64プレビュー
function renderPreview() {
  revokePreviewBlobURLs();
  preview.innerHTML = "";
  if (pickedFiles.length === 0) {
    preview.insertAdjacentHTML("beforeend", `<div class="muted" style="grid-column: 1 / -1;">(画像がありません)</div>`);
    return;
  }
  pickedFiles.forEach((f, idx) => {
    const url = URL.createObjectURL(f);
    const el = document.createElement("div");
    el.className = "thumb";
    el.title = (idx+1) + "枚目: " + f.name;
    el.innerHTML = `
      <img src="${url}" alt="${f.name}">
      <button class="delbtn" data-index="${idx}" aria-label="削除">×</button>
    `;
    preview.appendChild(el);
  });
  // 空枠(maxImages枚に満たない時)
  for (let i = pickedFiles.length; i < maxImages; i++) {
    const ph = document.createElement("div");
    ph.className = "thumb";
    ph.innerHTML = `<span class="small muted">空</span>`;
    preview.appendChild(ph);
  }
}
// 個別削除
preview.addEventListener("click", (e) => {
  const btn = e.target.closest(".delbtn");
  if (!btn) return;
  e.preventDefault();
  const idx = Number(btn.dataset.index);
  if (!Number.isInteger(idx)) return;
  pickedFiles.splice(idx, 1);
  uploadedUrls.splice(idx, 1);
  renderPreview();
});

function handleFiles(files) {
  const imgs = Array.from(files).filter(f => f.type.startsWith("image/"));
  if (imgs.length === 0) { setLog("画像ファイルを選択してください。"); return; }
  for (const f of imgs) {
    pickedFiles.push(f);
    uploadedUrls.push(null);   // 追加分だけnull埋め
  }
  // 上限超過分は先頭から落とす
  if (pickedFiles.length > maxImages) {
    const over = pickedFiles.length - maxImages;
    pickedFiles.splice(0, over);
    uploadedUrls.splice(0, over);
  }
  renderPreview();
}

drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("dragover"); });
drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("dragover"); handleFiles(e.dataTransfer.files); });

runBtn.addEventListener("click", async () => {
  result.innerHTML = "";
  setLog("");

  if (pickedFiles.length === 0) { setLog(`先に画像をドロップしてください。(最大${maxImages}枚)`); return; }
  const prompt = (promptEl.value || "").trim();
  if (!prompt) { setLog("プロンプトを入力してください。"); return; }

  const numImages     = Number(numImagesEl.value) || 1; // 1 or 2
  const output_format = formatEl.value;                 // "jpeg" or "png"

  runBtn.disabled = true; runBtn.textContent = "実行中…";

  try {
    // 1) アップロード
    if ( uploadedUrls.some(u => u === null) ) {
      setLog("アップロード中...");
      for (let idx = 0; idx < pickedFiles.length; idx++) {
        const f = pickedFiles[idx];
        if (uploadedUrls.length <= idx || !uploadedUrls[idx]) {
          const url = await fal.storage.upload(f);
          uploadedUrls[idx] = url;
        }
      }
      appendLog("完了\n実行中...");
    } else {
      appendLog("アップロード不要(済)\n実行中...");
    }
    // 2) 実行
    const res = await fal.subscribe(MODEL, {
      input: {
        prompt,
        image_urls: uploadedUrls,
        num_images: numImages,
        output_format
      },
      logs: true,
      onQueueUpdate: (u) => {
        if (u && u.status === "IN_PROGRESS" && Array.isArray(u.logs) && u.logs.length) {
          const lines = u.logs.map(l => l.message).join("\n");
          appendLog(lines + "\n");
        }
      }
    });
    // 3) 表示
    const images = (res && res.data && res.data.images) ? res.data.images : [];
    if (!images.length) {
      appendLog("完了(画像なし)\n");
      result.innerHTML = `<div class="errormsg">画像が返りませんでした。プロンプトを調整して再試行してください。</div>`;
    } else {
      appendLog("完了\n");
      for (var i = 0; i < Math.min(images.length, 2); i++) {
        const img = images[i];
        const card = document.createElement("div");
        card.className = "card";
        card.innerHTML = `
          <a href="${img.url}" target="_blank" rel="noopener">
            <img src="${img.url}" alt="result">
          </a>
          <div style="padding:8px; display:flex; gap:8px;">
            <a href="${img.url}" download>ダウンロード</a>
            <span class="muted small">(クリックで原寸表示)</span>
          </div>`;
        result.appendChild(card);
      }
    }
  } catch (err) {
    const status = getErrStatus(err);
    if (status === 422) {
      appendLog("完了(422 エラー)\n");
      result.innerHTML = `<div class="errormsg">HTTP 422 エラー。プロンプトを見直してください(安全フィルタによる規制・処理困難なプロンプトの可能性)。</div>`;
    } else {
      appendLog("完了(エラー)\n");
          result.innerHTML = `<div class="errormsg">エラー発生(${status || "unknown"}): ${getErrMessage(err)}</div>`;
    }
  } finally {
      runBtn.disabled = false;
      runBtn.textContent = "実行";
  }
});

window.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll(".maxImages").forEach(el => {
    el.textContent = maxImages;
  });
  document.getElementById("preview").style.gridTemplateColumns = `repeat(${maxImages}, 64px)`;
});

renderPreview();
  </script>
</head>
<body>
  <h1>Gemini 2.5 Flash Image (fal-ai/nano-banana/edit)</h1>
  <div id="drop" class="zone"><!-- 1) ドロップ -->
    ここに画像をドラッグ&ドロップ(最大<span class="maxImages"></span>枚)<br/>
  </div>

  <div class="row"><!-- 2) 64×64 プレビュー -->
    <label>プレビュー(64×64 / 最大<span class="maxImages"></span>枚)</label>
    <div id="preview"></div>
  </div>

  <div class="row"><!-- 3) プロンプト -->
    <label for="prompt">プロンプト</label>
    <textarea id="prompt" placeholder="背景を秋の京都の路地にして。"></textarea>
    <div class="controls">
      <label class="inline">生成枚数
        <select id="numImages">
          <option value="1">1枚</option>
          <option value="2">2枚</option>
        </select>
      </label>
      <label class="inline">出力形式
        <select id="format">
          <option value="jpeg">jpeg</option>
          <option value="png">png</option>
        </select>
      </label>
    </div>
  </div>

  <div class="row"><!-- 4) 実行 -->
    <button id="run">実行</button>
  </div>

  <div class="row"><!-- 5) 状況 -->
    <label>状況</label>
    <div id="log" class="muted"></div>
  </div>

  <div class="row"><!-- 6) 結果 -->
    <label>結果</label>
    <div id="result"></div>
  </div>
</body>
</html>

gemini-image.css

body { font-family: system-ui, sans-serif; margin: 24px; line-height: 1.6; }
h1 { margin: 0 0 16px; font-size: 20px; }
.zone { border: 2px dashed #888; border-radius: 10px; padding: 24px; text-align: center; transition: .15s; }
.zone.dragover { border-color: #333; background: #fafafa; }
.row { margin-top: 14px; }
#preview { display: grid; gap: 10px; }
.thumb { position: relative; width: 64px; height: 64px; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; display: grid; place-items: center; }
.thumb img { width: 64px; height: 64px; object-fit: cover; display: block; }
.delbtn {
  position: absolute; top: 2px; right: 2px;
  width: 18px; height: 18px; line-height: 16px; text-align: center;
  border: 1px solid rgba(0,0,0,.25); border-radius: 50%;
  background: rgba(255,255,255,.9); cursor: pointer; user-select: none;
  font-weight: 700; font-size: 12px; padding: 0;
}
input[type="file"].file-input-hidden { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
.delbtn:hover { background: #fff; }
.muted { color: #666; font-size: 12px; }
.errormsg { color: red; font-size: 12px; }
textarea { width: calc(100% - 36px); min-height: 40px; padding: 10px; font-size: 14px; }
button { padding: 9px 14px; font-size: 14px; cursor: pointer; margin-right: 8px; }
select { padding: 6px 8px; font-size: 14px; }
#result { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); }
.card { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; background: #fff; }
.card img { width: 100%; display: block; }
#log { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: #444; background:#f7f7f7; padding:10px; border-radius:6px; }
.controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.inline { display: inline-flex; align-items: center; gap: 6px; }
.small { font-size: 12px; }

gemini-credential.js

window.FAL_KEY = "ここにAPIキーを入力する";

画像生成

Webページ

Webページはこんな感じです。
凝ったデザインなど不要です。目的は生成された画像の方なのです。

gemini-image-html.jpg

実行例1

はぐれメタルの画像を1枚を入力して、以下のプロンプトを入力しました。

はぐれメタルを10匹に増やして、重ねる

すると、縦方向に重ねてくれました。見えないところを含めると、確かに10匹はいます。
よく見ると、画像のアングルが全て違います。また、重なっている下の部分はきちんと自然な暗さです。すごいですね。

seisei1.jpg

実行例2

貝汁の画像を1枚を入力して、以下のプロンプトを入力しました。

貝汁の具の量を増やす

すると、2人前ぐらいまで具の量を多くしてくれました。
大サービスですね。
ネギも適度に振られていて、これも自然な画像に仕上がっています。

seisei2.jpg

実行例3

はぐれメタル、貝汁、刺身の画像の合計3枚を入力して、以下のプロンプトを入力しました。

貝汁の具の量を増やす、貝汁の具に刺身を入れる、はぐれメタルを10匹に増やして貝汁の上に重ねる

すると、何回か HTTP 422 エラーが表示されました。
公序良俗に反するプロンプトだと理解できるのですが、そんなわけでもないのに。。
何回か試すと、質問が複雑で意味が分からないと、エラーになるような感じでした。
でも、しつこく実施すると、以下の画像が生成されました。

ちゃんと貝汁に刺身が入っています!
はぐれメタルは入っていますが、数が違うようです。この辺り、プロンプトのすべてが満たせない場合にできる範囲で実施するという、画像生成AIの特徴ですね。
はぐれメタルの明るさが適度に調整されています!汁にも上手に浸っています。
意味不明な画像ですが、物理現象としては自然ですね。

seisei3.jpg

おわりに

先程記載したHTML, CSS, Javascript は、Windows 11で確認しましたが、特別な環境依存はなさそうです。
同じようにガンガン試したいけど、Googleアカウントで直接したくないという人がおられたら、ぜひ試してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?