見出し画像

『Nano-bananaを使ってオリキャラの表情やポーズを自在に生成するツール』を自作したので実装方法を共有します♪

こんにちは、あるいは こんばんは。ジロウです♪
僕の note を開いてくださり、ありがとうございます。

◆はじめに

まず最初に申し上げておきたいことがあります。

このツールが出来上がるきっかけとなった、特大なヒントを下さったのは、
ざすこさん(@zasuko_michiksa
で、そのポストがコレ⇩なんです♪

https://twitter.com/zasuko_michiksa/status/1962768498419179699

まじヤバい御仁です♪ もはや神ですよね♪ 尊いぃ~っ!😆🙏

これが無ければ、僕は なぁ~んにも出来ないダメなオヤジだったのです(笑)

そんな現実をご理解いただいた上で、お話を進めて参ります!!


このnoteでは、
先日、僕がポストで雄叫びを上げたこのツール…

これの最新版『Fusion Sketch v8』を実装する方法を解説します。

まず、このツールで出来ることをまとめておきますね。


◆AIに参照させる画像を最大4枚までアップロードできます。
nano-bananaのお陰で、どんな元画像であろうとキャラ固定の精度はめっちゃ高いです♪

◆ポーズを指定する方法は2つ!
 ①キャラにさせたいポーズをした別画像をアップロードして参照させる
 ②棒人間を描いて指示する
 ※ ①②をミックスして指示してもOKです♪

◆棒人間は、描画色ごとにアップロードした画像にリンクしており、複数キャラの各々にポーズを指定することができます。
※ただし、生成結果の精度は それほど高くありません💦
 画像1:白
 画像2:赤
 画像3:青
 画像4:緑

◆プロンプトで構図や表情を指示できます。
複数のキャラや複雑な構図はAIが認識しづらくなるので、プロンプトで詳しい指示を加えることで精度を上げられる…かも知れません💦

◆アスペクト比を選択するプルダウンメニューがありますが、現時点ではAI側の仕様のためか機能していません…💦

◆AIへの負荷の状況によって生成できないエラーが発生することがあるので、それを回避するために、自動的に再試行する機能を装備しています。


というわけで、とっとと実装方法の解説に移りますね♪

【注!】大前提として、Googleアカウントが必要です。




◆『Fusion Sketch v8』HTMLコード

早速ですが、
ひとまず下記のコードを メモ帳やエディタにコピペしてください。
けっこう長いです🤣www

※カーソルを乗せると、右上にコピーボタンが表示されます♪


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ここに、お好みのツール名を入力</title>
    <!-- Tailwind CSSの読み込み -->
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
        body {
            font-family: 'Inter', sans-serif;
            background-color: #1a202c; /* ダークモードの背景 */
            color: #e2e8f0; /* 明るい文字色 */
        }
        .container {
            max-width: 1200px;
        }
        .upload-area {
            border: 2px dashed #4a5568;
            background-color: #2d3748;
            transition: all 0.3s ease;
        }
        .upload-area:hover {
            border-color: #63b3ed;
            background-color: #4a5568;
        }
        .preview-image {
            max-height: 200px;
            object-fit: contain;
        }
        #drawingCanvas {
            border: 2px solid #4a5568;
            background-color: #1a202c;
            touch-action: none;
            max-width: 100%;
            height: auto;
        }
        .message-box {
            display: none;
        }
        .button-primary {
            background-image: linear-gradient(to right, #9d4edd, #ff71ce);
            color: #ffffff;
            transition: all 0.3s ease;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
        }
        .button-primary:hover {
            transform: translateY(-2px) scale(1.02);
            box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
        }
        .button-secondary {
            background-image: linear-gradient(to right, #40e0d0, #63a4ff);
            color: #ffffff;
            transition: all 0.3s ease;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
        }
        .button-secondary:hover {
            filter: brightness(1.2);
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
        }
        .button-tool {
            background-color: #4a5568;
            color: #e2e8f0;
            transition: all 0.2s ease;
        }
        .button-tool:hover {
            background-color: #63b3ed;
        }
        .button-tool.active {
            background-color: #63b3ed;
            color: #1a202c;
        }
        .button-download {
            background-color: #10b981;
            color: #ffffff;
            transition: all 0.3s ease;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }
        .button-download:hover {
            background-color: #059669;
            transform: translateY(-1px);
            box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
        }
        .card {
            background-color: #2d3748;
            box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
        }
        /* タイトルにグラデーションを適用 */
        .title-gradient {
            background-image: linear-gradient(to right, #ff9a8b, #ff6a88, #ff99ac);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        /* 新しい色選択ボタンのスタイル */
        .color-button {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            cursor: pointer;
            border: 3px solid transparent;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
            transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
        }
        .color-button:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3);
        }
        .color-button.active {
            border-color: #e0e0e0;
            transform: scale(1.1);
        }
    </style>
</head>
<body class="p-6 md:p-12">
    <div class="container mx-auto">
        <header class="text-center mb-10">
            <h1 class="text-3xl md:text-5xl font-extrabold text-white mb-2 title-gradient">
                Fusion Sketch
            </h1>
            <p class="text-sm md:text-base text-gray-400">想いをカタチにするAIイラストツール</p>
        </header>

        <main class="grid grid-cols-1 md:grid-cols-2 gap-8">
            <!-- 左カラム -->
            <div class="flex flex-col gap-8">
                <!-- メッセージボックス -->
                <div id="messageBox" class="message-box bg-red-800 border border-red-600 text-red-200 px-4 py-3 rounded-lg" role="alert">
                    <p id="messageText"></p>
                </div>

                <!-- 参照画像アップロードエリア -->
                <div>
                    <h2 class="text-xl font-bold mb-4">① 参照画像をアップロード (スタイル、雰囲気など)</h2>
                    <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
                        <div class="upload-area flex flex-col justify-center items-center rounded-xl p-4 cursor-pointer" onclick="document.getElementById('imageInput1').click()">
                            <input type="file" id="imageInput1" accept="image/jpeg, image/png, image/webp" class="hidden">
                            <div id="preview1" class="w-full h-48 flex justify-center items-center text-center">
                                <span class="text-gray-400">画像1 (白い線) をアップロード</span>
                            </div>
                        </div>
                        <div class="upload-area flex flex-col justify-center items-center rounded-xl p-4 cursor-pointer" onclick="document.getElementById('imageInput2').click()">
                            <input type="file" id="imageInput2" accept="image/jpeg, image/png, image/webp" class="hidden">
                            <div id="preview2" class="w-full h-48 flex justify-center items-center text-center">
                                <span class="text-gray-400">画像2 (赤い線) をアップロード</span>
                            </div>
                        </div>
                        <div class="upload-area flex flex-col justify-center items-center rounded-xl p-4 cursor-pointer" onclick="document.getElementById('imageInput3').click()">
                            <input type="file" id="imageInput3" accept="image/jpeg, image/png, image/webp" class="hidden">
                            <div id="preview3" class="w-full h-48 flex justify-center items-center text-center">
                                <span class="text-gray-400">画像3 (青い線) をアップロード</span>
                            </div>
                        </div>
                        <div class="upload-area flex flex-col justify-center items-center rounded-xl p-4 cursor-pointer" onclick="document.getElementById('imageInput4').click()">
                            <input type="file" id="imageInput4" accept="image/jpeg, image/png, image/webp" class="hidden">
                            <div id="preview4" class="w-full h-48 flex justify-center items-center text-center">
                                <span class="text-gray-400">画像4 (緑の線) をアップロード</span>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- ポーズ指定エリア -->
                <div>
                    <h2 class="text-xl font-bold mb-4">② ポーズを指定</h2>
                    <div class="card rounded-xl p-4 mb-4">
                        <h3 class="text-lg font-semibold mb-2">ポーズの見本画像をアップロード (任意)</h3>
                        <div class="upload-area flex flex-col justify-center items-center rounded-xl p-4 cursor-pointer" onclick="document.getElementById('imageInputPose').click()">
                            <input type="file" id="imageInputPose" accept="image/jpeg, image/png, image/webp" class="hidden">
                            <div id="previewPose" class="w-full h-48 flex justify-center items-center text-center">
                                <span class="text-gray-400">ポーズ画像をアップロード</span>
                            </div>
                        </div>
                    </div>
                    <div class="card rounded-xl p-2 flex flex-col items-center">
                        <h3 class="text-lg font-semibold mb-2">棒人間を描く (任意)</h3>
                        <canvas id="drawingCanvas" class="rounded-lg border-2 border-gray-600"></canvas>
                        <div class="flex flex-wrap gap-2 justify-center mt-2">
                            <!-- 描画ツールボタン -->
                            <button id="drawBtn" class="button-tool font-semibold py-2 px-4 rounded-full active">描画</button>
                            <button id="eraserBtn" class="button-tool font-semibold py-2 px-4 rounded-full">消しゴム</button>
                            <!-- 新しい色選択ボタン -->
                            <button id="color-white" class="color-button active" style="background-color: white;" data-color="white"></button>
                            <button id="color-red" class="color-button" style="background-color: red;" data-color="red"></button>
                            <button id="color-blue" class="color-button" style="background-color: blue;" data-color="blue"></button>
                            <button id="color-green" class="color-button" style="background-color: green;" data-color="green"></button>
                            <!-- 履歴操作ボタン -->
                            <button id="undoBtn" class="button-tool font-semibold py-2 px-4 rounded-full">一つ前に戻る</button>
                            <button id="clearCanvasBtn" class="button-secondary font-semibold py-2 px-4 rounded-full">クリア</button>
                        </div>
                    </div>
                </div>
            </div>

            <!-- 右カラム -->
            <div class="flex flex-col gap-8">
                <!-- プロンプト入力 -->
                <div>
                    <h2 class="text-xl font-bold mb-4">③ 生成イメージを補足するプロンプト</h2>
                    <textarea id="promptTextarea" rows="3" class="w-full p-4 rounded-xl border-2 border-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors bg-gray-800 text-white"></textarea>
                </div>

                <!-- アスペクト比指定 -->
                <div>
                    <h2 class="text-xl font-bold mb-4">アスペクト比を選択</h2>
                    <select id="aspectRatioSelect" class="w-full p-2 rounded-xl border-2 border-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors bg-gray-800 text-white">
                        <option value="no">自由</option>
                        <option value="4:3">4:3</option>
                        <option value="3:4">3:4</option>
                        <option value="16:9">16:9</option>
                        <option value="9:16">9:16</option>
                    </select>
                </div>

                <!-- 生成ボタン -->
                <div class="text-center">
                    <button id="generateBtn" class="button-primary font-bold py-4 px-12 rounded-full text-lg">
                        <span id="buttonText">イラストを生成</span>
                        <span id="loadingIndicator" class="hidden spinner ml-2"></span>
                    </button>
                </div>

                <!-- 結果表示エリア -->
                <div class="bg-gray-800 rounded-xl p-6 card">
                    <h2 class="text-xl font-bold mb-4">④ 生成結果</h2>
                    <div class="bg-gray-90- rounded-lg overflow-hidden relative shadow-inner flex justify-center items-center" style="height: 400px;">
                        <img id="generatedImage" class="hidden object-contain max-h-full">
                        <div id="resultPlaceholder" class="text-center text-gray-500">
                            生成されたイラストがここに表示されます。
                        </div>
                    </div>
                    <div class="text-center mt-4">
                        <button id="downloadBtn" class="button-download font-semibold py-2 px-6 rounded-full hidden">画像をダウンロード</button>
                    </div>
                </div>
            </div>
        </main>
    </div>

    <script type="module">
        // グローバル変数の設定(環境から自動的に提供されます)
        const apiKey = 'ここに、あなたのAPIキーを入力'; // 本来は環境変数から取得されます。
        const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent?key=${apiKey}`;

        // UIエレメントの取得
        const imageInput1 = document.getElementById('imageInput1');
        const imageInput2 = document.getElementById('imageInput2');
        const imageInput3 = document.getElementById('imageInput3');
        const imageInput4 = document.getElementById('imageInput4');
        const imageInputPose = document.getElementById('imageInputPose'); // 新しいポーズ用入力
        const preview1 = document.getElementById('preview1');
        const preview2 = document.getElementById('preview2');
        const preview3 = document.getElementById('preview3');
        const preview4 = document.getElementById('preview4');
        const previewPose = document.getElementById('previewPose'); // 新しいポーズ用プレビュー
        const drawingCanvas = document.getElementById('drawingCanvas');
        const promptTextarea = document.getElementById('promptTextarea');
        const generateBtn = document.getElementById('generateBtn');
        const buttonText = document.getElementById('buttonText');
        const loadingIndicator = document.getElementById('loadingIndicator');
        const generatedImage = document.getElementById('generatedImage');
        const resultPlaceholder = document.getElementById('resultPlaceholder');
        const downloadBtn = document.getElementById('downloadBtn');
        const clearCanvasBtn = document.getElementById('clearCanvasBtn');
        const undoBtn = document.getElementById('undoBtn');
        const drawBtn = document.getElementById('drawBtn');
        const eraserBtn = document.getElementById('eraserBtn');
        const messageBox = document.getElementById('messageBox');
        const messageText = document.getElementById('messageText');
        const aspectRatioSelect = document.getElementById('aspectRatioSelect');

        // 新しい要素の取得
        const colorButtons = document.querySelectorAll('.color-button');

        const ctx = drawingCanvas.getContext('2d');
        const BASE_WIDTH = 400; // キャンバスの基準幅
        const BASE_HEIGHT = 400; // キャンバスの基準高

        // 描画設定を初期化する関数
        let currentColor = 'white'; // 現在の描画色を追跡する変数
        let currentTool = 'draw'; // 現在のツールを追跡する変数 ('draw' or 'erase')

        function setupCanvasContext() {
            ctx.lineCap = 'round';
            ctx.lineJoin = 'round';
            if (currentTool === 'draw') {
                ctx.lineWidth = 4;
                ctx.strokeStyle = currentColor;
                ctx.globalCompositeOperation = 'source-over';
            } else { // 'erase'
                ctx.lineWidth = 15;
                ctx.globalCompositeOperation = 'destination-out';
            }
        }

        let isDrawing = false;
        let history = []; // キャンバスの状態を保存する配列
        let step = -1;

        // アスペクト比に基づいてキャンバスのサイズを更新する
        function updateCanvasSize() {
            const aspectRatio = aspectRatioSelect.value;
            let newWidth = BASE_WIDTH;
            let newHeight = BASE_HEIGHT;

            switch (aspectRatio) {
                case '4:3':
                    newHeight = (BASE_WIDTH / 4) * 3;
                    break;
                case '3:4':
                    newWidth = (BASE_HEIGHT / 4) * 3;
                    break;
                case '16:9':
                    newHeight = (BASE_WIDTH / 16) * 9;
                    break;
                case '9:16':
                    newWidth = (BASE_HEIGHT / 16) * 9;
                    break;
                case 'no':
                default:
                    newWidth = BASE_WIDTH;
                    newHeight = BASE_HEIGHT;
                    break;
            }

            // 現在のキャンバス内容を一時保存
            const tempImage = new Image();
            tempImage.src = drawingCanvas.toDataURL();
            
            // 新しいサイズに設定
            drawingCanvas.width = newWidth;
            drawingCanvas.height = newHeight;

            // スタイルを調整してレスポンシブに対応
            drawingCanvas.style.height = `${newHeight / (BASE_WIDTH / 400)}px`;
            
            // 一時保存した内容を新しいキャンバスに描画
            tempImage.onload = () => {
                ctx.drawImage(tempImage, 0, 0, newWidth, newHeight);
                setupCanvasContext(); // キャンバス設定を再度適用
                saveCanvasState();
            };
        }

        aspectRatioSelect.addEventListener('change', updateCanvasSize);

        // キャンバスの状態を保存する
        function saveCanvasState() {
            step++;
            if (step < history.length) {
                history.length = step;
            }
            history.push(drawingCanvas.toDataURL());
        }

        // キャンバスの状態を復元する
        function restoreCanvasState() {
            if (step >= 0) {
                const img = new Image();
                img.onload = function() {
                    ctx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
                    ctx.drawImage(img, 0, 0);
                    setupCanvasContext(); // キャンバス設定を再度適用
                };
                img.src = history[step];
            } else {
                ctx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
            }
        }

        // メッセージ表示関数
        function showMessage(message, isError = false) {
            messageText.textContent = message;
            messageBox.style.display = 'block';
            messageBox.className = `message-box px-4 py-3 rounded-lg ${isError ? 'bg-red-800 border border-red-600 text-red-200' : 'bg-green-800 border border-green-600 text-green-200'}`;
        }

        function hideMessage() {
            messageBox.style.display = 'none';
        }

        // 画像プレビュー機能
        function setupImagePreview(inputElement, previewElement, placeholderText) {
            inputElement.addEventListener('change', (event) => {
                const file = event.target.files[0];
                if (file) {
                    const reader = new FileReader();
                    reader.onload = (e) => {
                        const img = new Image();
                        img.onload = () => {
                            img.className = "preview-image w-full h-full object-contain rounded-lg";
                            previewElement.innerHTML = '';
                            previewElement.appendChild(img);
                        };
                        img.src = e.target.result;
                    };
                    reader.readAsDataURL(file);
                } else {
                    previewElement.innerHTML = `<span class="text-gray-400">${placeholderText}</span>`;
                }
            });
        }
        setupImagePreview(imageInput1, preview1, '画像1 (白い線) をアップロード');
        setupImagePreview(imageInput2, preview2, '画像2 (赤い線) をアップロード');
        setupImagePreview(imageInput3, preview3, '画像3 (青い線) をアップロード');
        setupImagePreview(imageInput4, preview4, '画像4 (緑の線) をアップロード');
        setupImagePreview(imageInputPose, previewPose, 'ポーズ画像をアップロード');

        // キャンバス描画機能
        function getMousePos(canvas, event) {
            const rect = canvas.getBoundingClientRect();
            const scaleX = canvas.width / rect.width;
            const scaleY = canvas.height / rect.height;
            const clientX = event.clientX || event.touches[0].clientX;
            const clientY = event.clientY || event.touches[0].clientY;
            return {
                x: (clientX - rect.left) * scaleX,
                y: (clientY - rect.top) * scaleY
            };
        }

        drawingCanvas.addEventListener('mousedown', (e) => {
            isDrawing = true;
            const pos = getMousePos(drawingCanvas, e);
            ctx.beginPath();
            ctx.moveTo(pos.x, pos.y);
        });

        drawingCanvas.addEventListener('mousemove', (e) => {
            if (!isDrawing) return;
            const pos = getMousePos(drawingCanvas, e);
            ctx.lineTo(pos.x, pos.y);
            ctx.stroke();
        });

        drawingCanvas.addEventListener('mouseup', () => {
            if (isDrawing) {
                isDrawing = false;
                ctx.closePath();
                saveCanvasState();
            }
        });

        drawingCanvas.addEventListener('mouseleave', () => {
            if (isDrawing) {
                isDrawing = false;
                ctx.closePath();
                saveCanvasState();
            }
        });
        
        // タッチイベントの追加
        drawingCanvas.addEventListener('touchstart', (e) => {
            e.preventDefault();
            isDrawing = true;
            const pos = getMousePos(drawingCanvas, e);
            ctx.beginPath();
            ctx.moveTo(pos.x, pos.y);
        });

        drawingCanvas.addEventListener('touchmove', (e) => {
            e.preventDefault();
            if (!isDrawing) return;
            const pos = getMousePos(drawingCanvas, e);
            ctx.lineTo(pos.x, pos.y);
            ctx.stroke();
        });

        drawingCanvas.addEventListener('touchend', () => {
            if (isDrawing) {
                isDrawing = false;
                ctx.closePath();
                saveCanvasState();
            }
        });

        // ツールボタンの機能
        drawBtn.addEventListener('click', () => {
            currentTool = 'draw';
            setupCanvasContext();
            drawBtn.classList.add('active');
            eraserBtn.classList.remove('active');
        });

        eraserBtn.addEventListener('click', () => {
            currentTool = 'erase';
            setupCanvasContext();
            eraserBtn.classList.add('active');
            drawBtn.classList.remove('active');
        });

        // 新しい色選択ボタンのイベントリスナー
        colorButtons.forEach(button => {
            button.addEventListener('click', (e) => {
                // すべてのボタンからactiveクラスを削除
                colorButtons.forEach(btn => btn.classList.remove('active'));

                // クリックされたボタンにactiveクラスを追加
                e.target.classList.add('active');

                // ツールを「描画」に設定
                currentTool = 'draw';
                drawBtn.classList.add('active');
                eraserBtn.classList.remove('active');
                
                // 描画色を更新
                currentColor = e.target.dataset.color;
                setupCanvasContext();
            });
        });

        // 「一つ前に戻る」
        undoBtn.addEventListener('click', () => {
            if (step > 0) {
                step--;
                restoreCanvasState();
            }
        });

        // キャンバスをクリアする
        clearCanvasBtn.addEventListener('click', () => {
            ctx.clearRect(0, 0, drawingCanvas.width, drawingCanvas.height);
            history = [];
            step = -1;
            setupCanvasContext(); // クリア後も設定を初期化
        });

        // 初期状態の保存
        window.onload = function() {
            updateCanvasSize();
            setupCanvasContext();
            saveCanvasState();
        }

        // Base64に変換するヘルパー関数
        async function convertImageToBase64(file) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onloadend = () => resolve(reader.result.split(',')[1]);
                reader.onerror = reject;
                reader.readAsDataURL(file);
            });
        }

        // 指数関数的バックオフを使用してAPIを呼び出す関数
        async function callApiWithRetry(payload, retries = 3) {
            for (let i = 0; i < retries; i++) {
                try {
                    const response = await fetch(apiUrl, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify(payload)
                    });

                    if (response.ok) {
                        return await response.json();
                    } else {
                        const errorData = await response.json();
                        if (i < retries - 1) {
                             console.warn(`APIエラーが発生しました。再試行します (${i + 1}/${retries})`);
                             await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
                        } else {
                            throw new Error(`APIエラー: ${errorData.error?.message || response.statusText}`);
                        }
                    }
                } catch (error) {
                    if (i < retries - 1) {
                           console.warn(`ネットワークエラーが発生しました。再試行します (${i + 1}/${retries})`);
                           await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
                    } else {
                        throw error;
                    }
                }
            }
        }
        
        // 画像生成APIを呼び出す
        generateBtn.addEventListener('click', async () => {
            hideMessage();
            resultPlaceholder.textContent = '画像を生成中...';
            generatedImage.src = '';
            generatedImage.classList.add('hidden');
            downloadBtn.classList.add('hidden');
            generateBtn.disabled = true;
            buttonText.textContent = '生成中';
            loadingIndicator.classList.remove('hidden');

            try {
                const parts = [];
                const prompt = promptTextarea.value.trim();

                const canvasData = drawingCanvas.toDataURL('image/png').split(',')[1];
                parts.push({
                    inlineData: {
                        mimeType: 'image/png',
                        data: canvasData
                    }
                });

                const imagesWithColors = [
                    { file: imageInput1.files[0], color: 'white' },
                    { file: imageInput2.files[0], color: 'red' },
                    { file: imageInput3.files[0], color: 'blue' },
                    { file: imageInput4.files[0], color: 'green' },
                ];
                
                // 画像をプロンプトテキストで明確に指定する
                if (imageInput1.files[0]) {
                    parts.push({ text: `画像1 (白い線) を参照しています。` });
                    parts.push({
                        inlineData: {
                            mimeType: imageInput1.files[0].type,
                            data: await convertImageToBase64(imageInput1.files[0])
                        }
                    });
                }
                
                if (imageInput2.files[0]) {
                    parts.push({ text: `画像2 (赤い線) を参照しています。` });
                    parts.push({
                        inlineData: {
                            mimeType: imageInput2.files[0].type,
                            data: await convertImageToBase64(imageInput2.files[0])
                        }
                    });
                }

                if (imageInput3.files[0]) {
                    parts.push({ text: `画像3 (青い線) を参照しています。` });
                    parts.push({
                        inlineData: {
                            mimeType: imageInput3.files[0].type,
                            data: await convertImageToBase64(imageInput3.files[0])
                        }
                    });
                }
                
                if (imageInput4.files[0]) {
                    parts.push({ text: `画像4 (緑の線) を参照しています。` });
                    parts.push({
                        inlineData: {
                            mimeType: imageInput4.files[0].type,
                            data: await convertImageToBase64(imageInput4.files[0])
                        }
                    });
                }
                
                // ポーズ用画像があれば追加
                const poseImageFile = imageInputPose.files[0];
                if (poseImageFile) {
                    const poseImageData = await convertImageToBase64(poseImageFile);
                    parts.push({ text: "ポーズの参照画像" });
                    parts.push({
                        inlineData: {
                            mimeType: poseImageFile.type,
                            data: poseImageData
                        }
                    });
                }
                
                // プロンプトがあれば追加
                if (prompt) {
                    parts.push({ text: prompt });
                }

                if (parts.length === 0) {
                    showMessage('画像を生成するには、ポーズを描くか参照画像をアップロードしてください。', true);
                    return;
                }
                
                const payload = {
                    contents: [{ parts }],
                    generationConfig: {
                        responseModalities: ['IMAGE'],
                    },
                    systemInstruction: {
                        parts: [{
                            text: "ユーザーが提供した棒人間またはポーズ画像を、キャラクターのポーズとして非常に厳密に、かつ忠実に再現してください。提供された各画像は、対応する説明テキストで示されているように、特定の色の線と関連付けられています。これらの関連付けを厳密に守って画像を生成してください。"
                        }]
                    }
                };

                const result = await callApiWithRetry(payload);

                const base64Data = result?.candidates?.[0]?.content?.parts?.find(p => p.inlineData)?.inlineData?.data;

                if (base64Data) {
                    const imageUrl = `data:image/png;base64,${base64Data}`;
                    generatedImage.src = imageUrl;
                    generatedImage.classList.remove('hidden');
                    resultPlaceholder.textContent = '';
                    downloadBtn.classList.remove('hidden');
                    showMessage('画像の生成に成功しました。', false);
                } else {
                    showMessage('画像の生成に失敗しました。', true);
                }

            } catch (error) {
                console.error('エラー:', error);
                showMessage(`エラーが発生しました: ${error.message}`, true);
            } finally {
                generateBtn.disabled = false;
                buttonText.textContent = 'イラストを生成';
                loadingIndicator.classList.add('hidden');
            }
        });

        // 画像ダウンロード機能
        downloadBtn.addEventListener('click', () => {
            const link = document.createElement('a');
            link.href = generatedImage.src;
            link.download = 'generated_image.png';
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        });

    </script>

</body>
</html>


◆このツールの仕様について

画像

①参照画像をアップロード(スタイル、雰囲気など)
 nano-bananaさんの恩恵で、参照画像の一貫性を “まぁまぁ” 保って生成してくれます。

②ポーズを指定
 ポーズの見本画像をアップロードして参照させる、
 または、棒人間をマウスで描画して指示する、
 あるいは、これらの両方を複合して指示することができます。

③生成イメージを補足するプロンプト
 画像や棒人間だけでなく、狙いの絵柄や構図をテキストで指示することで、
 意図したイメージに より一層近づけることができます。

④アクセプト比を選択
 これについては、現在のところAI側の仕様のためか期待した結果が得られないことをご容赦ください。
 所感としては、参照画像のアクセプト比に引っ張られる傾向を感じています。


◆ツールの実装方法

【注!】大前提として、Googleアカウントが必要です。

➊上記のHTMLコードをメモ帳やエディタにコピペしてください。
 長くて大変ですけど…💦

➋下記を参考に、①②の2箇所をご自分のものに書き換えて下さい。

※「Ctrl + F」で太字部分を検索すれば見つかります♪

 ①コードの冒頭の方にある、
「 <title>ここに、お好みのツール名を入力</title>」
という部分です。

 ②コードの中盤にある、
「const apiKey = 'ここに、あなたのAPIキーを入力'; // 本来は環境変数から取得されます。」
という部分です。

 尚、
 APIキーは、Google Cloud のサイトで作成&取得できます。
 詳しくは、Geminiに尋ねると丁寧に教えてくれますが、
 下記サイトをはじめ、いくつか解説サイトがありますので参考にしてみて下さい。

https://mahover18.com/wp/google-api-key/

➌ HTMLコードのコピペ&2箇所の書き換えが完了したら、HTML形式でPC上に保存してください。
 例として、Windows11のメモ帳からHTML形式で保存する方法をご紹介します。

 ファイル > 名前を付けて保存 と進み、
 下図のように設定してPC上の任意の場所に保存しましょう。

 下図ではドキュメントを選択していますが、ひとまずはデスクトップでイイと思います。

画像

 ① お好きなファイル名を付けて、拡張子を「.htm」とします。
 ② ファイルの種類は、「すべてのファイル」を選択します。
 ③ エンコードは、「UTF-8」を選択しましょう。⇦重要!
  もしも、改行コードを求められた場合は「LF」を選択すればOKです。

【Tips!】
CR+LF: Windowsで使われる改行コードです。
LF: macOSやLinuxなどのUnix系OSで使われる改行コードで、Web開発では最も一般的な標準です。
CR: 古いMac OSで使われていた改行コードです。

➍保存したHTMLファイルをダブルクリックすれば、ブラウザ上でツールが開きます♪

➎安定して利用するには、有料プランへアップグレードすることをおススメします。

以下、Geminiに教わった料金システムについて引用します。


◆Google AIの料金システムについて


GoogleのAIサービスは、基本的に従量課金制です。これは、使用した分だけ料金が発生するシステムです。

  • 全API共通: 有料プランにアップグレードすると、そのGoogle Cloudプロジェクト内で利用する全てのAPI(今回の場合は画像生成、テキスト生成など)に設定されている無料枠が解除されます。各APIごとに個別に課金されるわけではありません。

  • 料金体系:

    • モデル別: 料金はモデルごとに異なります。たとえば、より高性能なモデルは、より高速なモデルよりも高価です。

    • 入力と出力: 多くのモデルは、入力(プロンプトや画像)と出力(生成された画像やテキスト)の量に応じて課金されます。


◆有料プランへのアップグレード手順


Google AI Studioから直接有料プランに移行することはできません。Google Cloud Platform(GCP)のコンソールから請求先アカウントを設定する必要があります。
大まかな手順は以下の通りです。

  1. Google Cloudコンソールにアクセス: https://console.cloud.google.com/ にアクセスし、あなたのGoogleアカウントでログインします。

  2. 新しいプロジェクトの作成(任意): もし、まだプロジェクトを作成していない場合は、新しいプロジェクトを作成してください。

  3. 請求先アカウントの設定:

    • コンソールのメニューから「お支払い」を選択します。

    • 「請求先アカウントをリンクする」または「請求先アカウントを作成」の指示に従って、支払い情報を登録します。クレジットカード情報の入力が求められます。

  4. 無料試用クレジット: 新規ユーザーの場合、通常は無料試用クレジット($300など)が提供されます。これを活用することで、実際の料金を支払う前に、有料プランの機能を試すことができます。ただし、無料試用期間が終了すると自動的に課金が開始される場合がありますので、注意が必要です。

上記の手順が完了すると、あなたのAPIキーの利用制限が緩和されます。


このnoteは、以上です♪
この度は、僕のツールに興味をお持ち下さり 誠にありがとうございます!
これからも仲良くしてやって下さいね♪

Special thanks to ざすこさん(@zasuko_michiksa)♪

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
Mid&niji journey術師🧙‍♂️ Stable Diffusion術師🧙‍♂️ AIイラスト×手描き補正🧑‍🔧 in CLIP STUDIO PAINT & Photoshop✍️|🌸#SOZO美術館 第12回&14回コンペ グランプリ受賞🥇|元ブログマネタイズ・コンサルタント
『Nano-bananaを使ってオリキャラの表情やポーズを自在に生成するツール』を自作したので実装方法を共有します♪|ジロウ🍀AIイラスト漫画クリエイター
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1