ทำเว็บอธิบายรูปด้วย Gemini Pro Vision + Next.js (App Router) ง่ายๆ บน Vercel

ไหนๆ ช่วงนี้ AI ก็มาแรงเหลือเกิน วันนี้เลยอยากพามาลองเล่น Gemini Pro Vision กันหน่อย คือมันเจ๋งตรงที่สามารถให้ AI มันช่วยอธิบายรูปที่เราอัพโหลดเข้าไปได้เลยนะ เหมาะมากสำหรับเอาไปทำฟีเจอร์เจ๋งๆ หรือแค่ลองเล่นขำๆ วันนี้เราจะใช้ Next.js App Router มาเป็นตัวขับเคลื่อน แล้วเอาขึ้น Vercel ให้มันจบๆ ไปเลย

ขั้นตอนไม่ได้ซับซ้อนมาก ถ้าคุ้นเคยกับ JavaScript/TypeScript มาบ้าง ก็น่าจะไปได้ไวเลยล่ะ


เตรียมโปรเจกต์ Next.js

อันดับแรกก็ต้องสร้างโปรเจกต์ Next.js ขึ้นมาก่อนเลยนะ ใช้คำสั่งนี้ได้เลย

pnpm create next-app nextjs-gemini-vision --ts --app --tailwind --eslint
# หรือ npm, yarn ก็ได้ แล้วแต่ถนัดนะ

พอสร้างเสร็จ ก็เข้าไปในโฟลเดอร์โปรเจกต์ แล้วติดตั้ง @google/generative-ai SDK กันต่อ

cd nextjs-gemini-vision
pnpm add @google/generative-ai

อย่าลืมไปสร้าง GEMINI_API_KEY ที่ Google AI Studio ด้วยนะ แล้วเอา Key ที่ได้มาใส่ในไฟล์ .env.local แบบนี้:

GEMINI_API_KEY=YOUR_API_KEY_ตรงนี้

สร้าง API Route สำหรับเชื่อม Gemini

ทีนี้เราจะมาสร้าง API Endpoint ให้ฝั่ง Frontend เรียกใช้กัน โดยจะสร้างใน app/api/describe-image/route.ts นะ

// app/api/describe-image/route.ts
import { GoogleGenerativeAI, HarmBlockThreshold, HarmCategory } from '@google/generative-ai';
import { NextResponse } from 'next/server';

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);

export async function POST(req: Request) {
  try {
    const { imageData } = await req.json(); // เราจะส่ง base64 string มาตรงนี้

    if (!imageData) {
      return NextResponse.json({ error: 'ไม่เจอข้อมูลรูปภาพนะ' }, { status: 400 });
    }

    // แปลง base64 string ให้เป็น parts ที่ Gemini เข้าใจ
    const imageParts = [{
      inlineData: {
        data: imageData.split(',')[1], // ตัด "data:image/jpeg;base64," ออกไป
        mimeType: imageData.split(',')[0].split(':')[1].split(';')[0] // ดึง mime type
      }
    }];

    const model = genAI.getGenerativeModel({ model: "gemini-pro-vision" });

    // กำหนด safety settings นิดนึง กัน AI ตอบอะไรแปลกๆ
    const safetySettings = [
      {
        category: HarmCategory.HARM_CATEGORY_HARASSMENT,
        threshold: HarmBlockThreshold.BLOCK_NONE,
      },
      // ... กำหนด categories อื่นๆ ได้ตามต้องการนะ
    ];

    const prompt = "รูปนี้มีอะไรบ้าง?"; // คำสั่งง่ายๆ อยากให้ AI อธิบายอะไรก็ใส่ตรงนี้เลย

    const result = await model.generateContent([prompt, ...imageParts], { safetySettings });
    const response = await result.response;
    const text = response.text();

    return NextResponse.json({ description: text }, { status: 200 });

  } catch (error) {
    console.error('Error calling Gemini API:', error);

    // เรื่อง error ตรงนี้นี่แหละที่ปวดหัว บางที Base64 มันใหญ่ไป
    // เคยเจอตอนรูปมันใหญ่มากๆ แล้วส่งเป็น JSON payload ไปตรงๆ
    // มันจะเจอ error "413 Payload Too Large" ซึ่งอันนี้ต้องไปแก้ที่ฝั่ง client ให้บีบรูป
    // หรือไม่ก็ต้องพิจารณาส่งแบบ multi-part form data ซึ่งมันยุ่งยากกว่าในบทความนี้
    // เอาเป็นว่า ถ้ามันเป็น error จาก Gemini เอง ก็ให้มันบอกไปตรงๆ
    if (error instanceof Error) {
        return NextResponse.json({ error: 'มีปัญหาบางอย่างกับ Gemini API หรือข้อมูลรูปภาพ: ' + error.message }, { status: 500 });
    }
    return NextResponse.json({ error: 'เกิดข้อผิดพลาดไม่ทราบสาเหตุ' }, { status: 500 });
  }
}

ความคิดเห็นส่วนตัว: ตอนแรกที่ลองทำตรงนี้คือปวดหัวกับเรื่องขนาดไฟล์รูปที่แปลงเป็น Base64 แล้วส่งไปใน JSON payload มากๆ เลยนะ เพราะถ้ามันใหญ่เกินไปนิดเดียวก็โดน 413 Payload Too Large ทันที วิธีแก้ก็คือต้องไปบีบรูปที่ฝั่ง Frontend ก่อนส่ง หรือไม่ก็เปลี่ยนไปใช้ FormData ส่งแบบ multipart/form-data ซึ่งมันดูยุ่งยากกว่าสำหรับตัวอย่างง่ายๆ แบบนี้อะนะ ฉะนั้นตัวอย่างนี้ก็เลยเน้นแค่ให้มันทำงานได้ง่ายๆ ไปก่อน


สร้างหน้า Frontend สำหรับอัพโหลดและแสดงผล

มาที่ไฟล์ app/page.tsx ของเรากัน ทีนี้เราจะสร้าง Component สำหรับให้อัพโหลดรูป และแสดงผลลัพธ์ที่ได้จาก Gemini กัน

// app/page.tsx
'use client'; // ต้องมีอันนี้นะ ถ้าจะใช้ client-side hook

import { useState } from 'react';

export default function HomePage() {
  const [selectedImage, setSelectedImage] = useState<File | null>(null);
  const [imageUrl, setImageUrl] = useState<string | null>(null);
  const [description, setDescription] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files && event.target.files[0]) {
      const file = event.target.files[0];
      setSelectedImage(file);
      setImageUrl(URL.createObjectURL(file)); // สร้าง URL สำหรับแสดงรูป
      setDescription(null); // เคลียร์คำอธิบายเก่า
      setError(null); // เคลียร์ error
    }
  };

  const handleDescribeImage = async () => {
    if (!selectedImage) {
      setError('กรุณาเลือกรูปภาพก่อนนะ');
      return;
    }

    setLoading(true);
    setError(null);
    setDescription(null);

    const reader = new FileReader();
    reader.readAsDataURL(selectedImage); // แปลงรูปเป็น Base64

    reader.onloadend = async () => {
      try {
        const base64Image = reader.result as string;

        // ตรงนี้จะเรียก API Route ที่เราสร้างไว้
        const response = await fetch('/api/describe-image', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ imageData: base64Image }),
        });

        const data = await response.json();

        if (response.ok) {
          setDescription(data.description);
        } else {
          setError(data.error || 'เกิดข้อผิดพลาดบางอย่าง');
        }
      } catch (err) {
        console.error('Fetch error:', err);
        setError('ไม่สามารถเชื่อมต่อกับ Server ได้ ลองใหม่อีกทีนะ');
      } finally {
        setLoading(false);
      }
    };

    reader.onerror = () => {
      setError('ไม่สามารถอ่านไฟล์รูปภาพได้');
      setLoading(false);
    };
  };

  return (
    <div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
      <div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-lg text-center">
        <h1 className="text-2xl font-bold mb-4">ให้ Gemini อธิบายรูปให้!</h1>

        <input 
          type="file" 
          accept="image/*" 
          onChange={handleImageChange} 
          className="mb-4 block w-full text-sm text-gray-500
            file:mr-4 file:py-2 file:px-4
            file:rounded-full file:border-0
            file:text-sm file:font-semibold
            file:bg-violet-50 file:text-violet-700
            hover:file:bg-violet-100"
        />

        {imageUrl && (
          <div className="mb-4">
            <img src={imageUrl} alt="Preview" className="max-w-full h-auto rounded-lg mx-auto" />
          </div>
        )}

        <button 
          onClick={handleDescribeImage} 
          disabled={!selectedImage || loading}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg
            disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
        >
          {loading ? 'กำลังคิด...' : 'อธิบายรูปนี้ให้หน่อย!'}
        </button>

        {error && (
          <p className="mt-4 text-red-600 font-medium">{error}</p>
        )}

        {description && (
          <div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 text-left">
            <h2 className="text-lg font-semibold mb-2">Gemini บอกว่า:</h2>
            <p className="text-gray-800 whitespace-pre-wrap">{description}</p>
          </div>
        )}
      </div>
    </div>
  );
}

ลอง pnpm dev แล้วเข้า http://localhost:3000 ดูก็จะเจอหน้าเว็บให้ลองอัพโหลดรูปแล้ว!


Deploy ขึ้น Vercel

ส่วนการเอาขึ้น Vercel นี่คือแทบจะง่ายที่สุดแล้วววว 1. Push code ขึ้น GitHub/GitLab/Bitbucket. 2. ไปที่ Vercel (ถ้ายังไม่มี Account ก็สมัครเลยฟรีๆ) แล้ว Import Git Repository ของเรา. 3. สำคัญมาก! อย่าลืมตั้งค่า GEMINI_API_KEY เป็น Environment Variable บน Vercel ด้วยนะ (ไปที่ Project Settings -> Environment Variables แล้วเพิ่ม Key เข้าไป) 4. กด Deploy! แค่นี้ก็เรียบร้อยแล้ว เว็บเราก็จะออนไลน์ให้คนอื่นได้ใช้ทันที


สรุป

เห็นมั้ยว่าการเอา AI มาใส่ในเว็บของเรามันไม่ได้ยากอย่างที่คิดเลยนะ แถมยังทำได้ไวมากๆ ด้วย Next.js App Router และ Vercel ที่ช่วยให้เราโฟกัสกับโค้ดจริงๆ ไม่ต้องมานั่ง Config อะไรให้ปวดหัวเยอะ ใครสนใจก็ลองเอาไปต่อยอดทำอะไรเจ๋งๆ กันได้เลย!

Read more

ไอลีน กู: ตำนานนักสกีฟรีสไตล์ผู้พลิกโฉมวงการและความหมายของชัยชนะ

ไอลีน กู: ตำนานนักสกีฟรีสไตล์ผู้พลิกโฉมวงการและความหมายของชัยชนะ

เจาะลึกเรื่องราวของ Eileen Gu นักสกีฟรีสไตล์ผู้สร้างประวัติศาสตร์ในโอลิมปิก 2026 สถิติที่ไม่เคยมีมาก่อน ประเด็นถกเถียง และความแข็งแกร่งส่วนตัวที่ทำให้เธอก้าวสู่ระดับโลก

By ทีมงาน devdog
วันพระ: คู่มือฉบับสมบูรณ์สำหรับพุทธศาสนิกชนและผู้สนใจยุคใหม่

วันพระ: คู่มือฉบับสมบูรณ์สำหรับพุทธศาสนิกชนและผู้สนใจยุคใหม่

เจาะลึกวันพระและความสำคัญของวันมาฆบูชา 2569 ทั้งวันหยุดราชการ ธนาคาร กิจกรรมเวียนเทียนต้นไม้ และผลกระทบต่อบริการขนส่ง เตรียมตัววางแผนทำบุญและพักผ่อน

By ทีมงาน devdog
ถอดรหัสรักแท้: "บังมัดคลองตันต้นข้าว" เรื่องราวที่สะท้อนการให้อภัยและการเริ่มต้นใหม่

ถอดรหัสรักแท้: "บังมัดคลองตันต้นข้าว" เรื่องราวที่สะท้อนการให้อภัยและการเริ่มต้นใหม่

เจาะลึกงานวิวาห์ "บังมัดคลองตัน" กับ "ต้นข้าว มิสแกรนด์" พร้อมเหตุผลจากใจเจ้าสาวที่เลือกความรักเหนือกาลเวลาและคำวิจารณ์ สู่การเริ่มต้นชีวิตคู่ที่สะท้อนการให้อภัย

By ทีมงาน devdog
ไฮไลท์บอลไทยลีก 2: มหาสารคาม เอสบีที เอฟซี กับฟอร์มร้อนแรงสู่เส้นทางเพลย์ออฟ

ไฮไลท์บอลไทยลีก 2: มหาสารคาม เอสบีที เอฟซี กับฟอร์มร้อนแรงสู่เส้นทางเพลย์ออฟ

เจาะลึกไฮไลท์บอลไทยลีก 2 ของมหาสารคาม เอสบีที เอฟซี กับฟอร์มร้อนแรง ชัยชนะสำคัญจาก ชิตชนก และบทบาทโค้ชดุสิต สู่เส้นทางเพลย์ออฟที่น่าจับตา!

By ทีมงาน devdog