العرض التجريبي الذي يموت في الإنتاج
بناء عرض تجريبي لتوليد الصور هو أسهل شيء في العالم. عشرة أسطر، ومفتاح واجهة برمجية واحد، وحقل للمطالبة، ثم تظهر صورة. العرض ينجح، والجميع يصفّق.
ثم ينتقل إلى الإنتاج فتصل الأسئلة الحقيقية دفعةً واحدة. ماذا يحدث حين يعيد النموذج نصًّا بدل صورة لأن مرشّح السلامة قد عمل؟ أين تضع ملف PNG بحجم 4 ميغابايت سينظر إليه المستخدم مرة واحدة؟ من يدفع حين يكتشف أحدهم نقطة النهاية غير المُوثَّقة لديك فيولّد أربعة عشر ألف صورة في ليلة؟ ولماذا يخرج النص العربي في الملصق بحروف مفكّكة؟ وحين يسألك العميل إن كانت الصورة مولَّدة بالذكاء الاصطناعي، بماذا تجيبه فعليًّا؟
هذا الدرس يبني النسخة التي تنجو من هذه الأسئلة. سنستخدم Nano Banana Pro من جوجل — نموذج توليد الصور وتحريرها المتاح عبر واجهة Gemini باسم gemini-3-pro-image-preview — داخل مشروع Next.js بمعمارية App Router، ونتعامل مع كل نقطة أعلاه بوصفها متطلبًا تصميميًّا لا فكرة لاحقة.
ما الذي ستبنيه
استوديو صور من صفحة واحدة، لكن بخلفية برمجية حقيقية:
- خدمة توليد مُحكمة الأنواع تتعامل مع نسبة الأبعاد والدقة وأنماط الفشل التي لا يُنمذجها لك حزمة التطوير
- مسار واجهة برمجية يتحقّق من المدخلات، ويطبّق حدًّا لمعدل الطلبات لكل مستخدم، ولا يثق أبدًا بالعميل في اختيار النموذج المُحاسَب عليه
- التحرير الحواري: ارفع صورة مرجعية، اطلب تعديلًا، واحصل على صورة جديدة تحافظ على اتّساق الموضوع
- عرض النص متعدد اللغات، بما في ذلك الحالة العربية التي تخفق فيها معظم نماذج الصور
- تخزين يحوّل إلى WebP قبل أن تلمس الصورة مستودعك، لأن مخرجات النموذج الخام ليست ما تريد تقديمه
- تدرّج في التكلفة بين النموذج السريع والنموذج الاحترافي، مع ذاكرة تخزين مؤقت تمنعك من الدفع مرتين مقابل المطالبة نفسها
في النهاية سيكون لديك نحو 400 سطر من كود التطبيق وتصوّر واضح لمسار إنفاق المال.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر:
- Node.js 20+ ومدير حزم (يستخدم هذا الدليل
pnpm) - مفتاح واجهة Gemini من Google AI Studio، مخزَّن في متغيّر بيئة
- إلمام بـ App Router في Next.js — معالجات المسارات، وإجراءات الخادم، والفصل بين مكوّنات الخادم والعميل
- أساسيات TypeScript — نعتمد على الأنواع لالتقاط أشكال الاستجابة المتغيّرة وقت التشغيل
- اختياري لكنه مُستحسن: مستودع متوافق مع S3 ونسخة Upstash Redis لخطوتي التخزين وتحديد المعدل
إن كانت واجهة Gemini نفسها جديدة عليك، فإن دليل واجهة Gemini مع TypeScript يغطّي الجانب النصّي من الحزمة نفسها، وهو رفيق مفيد لهذا الدرس.
الخطوة 1: تهيئة المشروع
أنشئ المشروع وثبّت حزمة التطوير:
pnpm create next-app@latest image-studio --typescript --app --tailwind
cd image-studio
pnpm add @google/genai zod sharp
pnpm add -D @types/nodeانتبه إلى اسم الحزمة. @google/genai هي حزمة Google Gen AI الموحّدة الحالية، وتغطّي واجهة Gemini للمطوّرين وVertex AI معًا. أما الحزمة الأقدم @google/generative-ai فهي مكتبة مختلفة ومهجورة — واتّباع درس مكتوب لها سيقودك إلى طريق مسدود.
ضع مفتاحك في .env.local:
GEMINI_API_KEY=your_key_hereلا تكشفه للمتصفّح أبدًا. في أي تنفيذ صحيح لا وجود لنسخة NEXT_PUBLIC_ من هذا المتغيّر — كل استدعاء للنموذج يجري على الخادم، خلف نقطة نهاية تتحكّم بها أنت. مفتاح نموذج صور مُسرَّب هو بطاقة ائتمان بعدّاد.
الخطوة 2: أصغر استدعاء ناجح
قبل بناء أي شيء حوله، تحقّق من واجهة البرمجة بسكربت عابر. أنشئ scripts/smoke.ts:
import { GoogleGenAI } from '@google/genai'
import { writeFileSync } from 'node:fs'
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
const response = await ai.models.generateContent({
model: 'gemini-3-pro-image-preview',
contents: 'A cross-section diagram of a Tunisian olive press, technical illustration style, labelled parts, warm ochre palette',
config: {
responseModalities: ['IMAGE'],
imageConfig: {
aspectRatio: '16:9',
imageSize: '2K',
},
},
})
for (const part of response.candidates?.[0]?.content?.parts ?? []) {
if (part.inlineData?.data) {
writeFileSync('out.png', Buffer.from(part.inlineData.data, 'base64'))
console.log('saved out.png')
}
}شغّله:
GEMINI_API_KEY=$GEMINI_API_KEY pnpm dlx tsx scripts/smoke.tsثلاث تفاصيل في هذا المقتطف أهم مما تبدو.
responseModalities: ['IMAGE'] هو ما يحوّل استدعاء نموذج نصّي إلى استدعاء صورة. أغفِله وستحصل على فقرة مهذّبة تصف معصرة زيتون.
imageConfig يحمل aspectRatio (القيم المدعومة تشمل 1:1 و2:3 و3:2 و3:4 و4:3 و9:16 و16:9 و21:9) وimageSize بقيم 1K و2K و4K، والافتراضي 1K. الدقة قرار محاسبي لا مقبض جودة ترفعه إلى أقصاه بحكم العادة: تكلفة 4K أعلى بوضوح لكل صورة، ومعظم الواجهات لا تعرضها أصلًا.
الاستجابة مصفوفة أجزاء، لا صورة. قد تحتوي الاستجابة الواحدة على أجزاء نصّية أو صورية أو كليهما، وأحيانًا يعيد النموذج نصًّا فقط. أي كود يصل مباشرةً إلى parts[0].inlineData.data سينهار في الإنتاج أول مرة تُفعِّل فيها مطالبةٌ مرشّحًا.
الخطوة 3: خدمة توليد تُنمذج الفشل
غلّف حزمة التطوير بشيء يستطيع معالج المسار الوثوق به. أنشئ lib/gemini-image.ts:
import { GoogleGenAI } from '@google/genai'
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! })
export const MODELS = {
fast: 'gemini-2.5-flash-image',
pro: 'gemini-3-pro-image-preview',
} as const
export type ModelTier = keyof typeof MODELS
export type ReferenceImage = {
data: string // base64 بدون بادئة data-URL
mimeType: string // 'image/png' | 'image/jpeg' | 'image/webp'
}
export type GenerateOptions = {
prompt: string
tier?: ModelTier
aspectRatio?: string
imageSize?: '1K' | '2K' | '4K'
references?: ReferenceImage[]
}
export type GenerateResult =
| { ok: true; image: Buffer; mimeType: string; note?: string }
| { ok: false; reason: 'no_image' | 'blocked' | 'error'; message: string }
export async function generateImage(
options: GenerateOptions,
): Promise<GenerateResult> {
const {
prompt,
tier = 'pro',
aspectRatio = '1:1',
imageSize = '2K',
references = [],
} = options
const parts: Array<Record<string, unknown>> = [
...references.map((ref) => ({
inlineData: { data: ref.data, mimeType: ref.mimeType },
})),
{ text: prompt },
]
try {
const response = await ai.models.generateContent({
model: MODELS[tier],
contents: [{ role: 'user', parts }],
config: {
responseModalities: ['IMAGE'],
imageConfig: { aspectRatio, imageSize },
},
})
const candidate = response.candidates?.[0]
if (candidate?.finishReason === 'SAFETY' || candidate?.finishReason === 'PROHIBITED_CONTENT') {
return {
ok: false,
reason: 'blocked',
message: 'تم حجب الطلب بواسطة مرشّح السلامة.',
}
}
let image: Buffer | null = null
let mimeType = 'image/png'
let note: string | undefined
for (const part of candidate?.content?.parts ?? []) {
if (part.inlineData?.data) {
image = Buffer.from(part.inlineData.data, 'base64')
mimeType = part.inlineData.mimeType ?? 'image/png'
} else if (part.text) {
note = part.text
}
}
if (!image) {
return {
ok: false,
reason: 'no_image',
message: note ?? 'لم يُعِد النموذج أي صورة لهذه المطالبة.',
}
}
return { ok: true, image, mimeType, note }
} catch (error) {
return {
ok: false,
reason: 'error',
message: error instanceof Error ? error.message : 'خطأ غير معروف',
}
}
}نوع القيمة المُعادة هو بيت القصيد. الاتحاد المُميَّز يُلزِم المستدعي بمعالجة «محجوب» و«لا صورة» كنتائج عادية لا استثناءات، وهو ما هي عليه بالضبط: رفض السلامة استجابة طبيعية من واجهة تعمل، لا خلل فيها.
لاحظ أيضًا أن references تأتي أولًا في مصفوفة الأجزاء وأن المطالبة النصّية تأتي أخيرًا. يقرأ النموذج الصور كسياق والنص الأخير كتعليمة؛ ووضع التعليمة أولًا يُضعف الالتزام بوضوح في مهام التحرير.
الخطوة 4: مسار الواجهة البرمجية
الآن نقطة النهاية. أنشئ app/api/generate/route.ts:
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { generateImage } from '@/lib/gemini-image'
import { storeImage } from '@/lib/storage'
import { checkRateLimit } from '@/lib/rate-limit'
import { getSession } from '@/lib/auth'
export const runtime = 'nodejs'
export const maxDuration = 60
const BodySchema = z.object({
prompt: z.string().min(3).max(2000),
aspectRatio: z.enum(['1:1', '3:4', '4:3', '9:16', '16:9', '21:9']).default('1:1'),
imageSize: z.enum(['1K', '2K']).default('2K'),
references: z
.array(
z.object({
data: z.string().max(10_000_000),
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp']),
}),
)
.max(3)
.default([]),
})
export async function POST(request: Request) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const limit = await checkRateLimit(session.userId)
if (!limit.success) {
return NextResponse.json(
{ error: 'Rate limit exceeded', resetAt: limit.reset },
{ status: 429 },
)
}
const parsed = BodySchema.safeParse(await request.json())
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 },
)
}
const result = await generateImage({
...parsed.data,
tier: session.plan === 'pro' ? 'pro' : 'fast',
})
if (!result.ok) {
const status = result.reason === 'blocked' ? 422 : 502
return NextResponse.json({ error: result.message, reason: result.reason }, { status })
}
const url = await storeImage(result.image, session.userId)
return NextResponse.json({ url, note: result.note })
}عدة قرارات هنا تستحق التصريح بها، لأنها بالضبط ما يقفز عنه الناس.
المصادقة تسبق التحقّق. نقطة نهاية صور بلا مصادقة ليست ثغرة أمنية بالمعنى المجرّد — إنها فاتورة مباشرة وقابلة للقياس. ثم يأتي تحديد المعدل، قبل أن تنفق مليمًا واحدًا على رموز النموذج.
العميل لا يختار النموذج. تُشتقّ tier من خطة الجلسة على الخادم. لو كان بوسع جسم الطلب ضبط tier: 'pro'، لأرسل كل مستخدم مجاني هذا الحقل خلال يوم من نشر نقطة نهايتك في أحد المنتديات.
المخطّط يستبعد 4K من imageSize. يمكنك إضافته للخطط المدفوعة، لكن المخطّط هو المكان الصحيح لاتخاذ هذا القرار بدل الوثوق بقائمة منسدلة.
maxDuration = 60. التوليد الاحترافي بدقة 2K يستغرق عادةً بين خمس عشرة وأربعين ثانية. المهلة الافتراضية في البيئات عديمة الخادم ستقطعك في منتصف التوليد، وستكون قد دفعت ثمن صورة لم تصلك.
لمعالجة أعمق لطبقة التحديد، راجع دليل تحديد المعدل عبر Upstash؛ وصفة النافذة المنزلقة هناك تندرج مباشرةً داخل checkRateLimit.
الخطوة 5: خزّن WebP، لا ما أعطاك إياه النموذج
يعيد النموذج ملفات PNG، وهي الصيغة الخطأ للتقديم: صورة مولَّدة بدقة 2K تقع بين 3 و6 ميغابايت، والصورة نفسها بصيغة WebP بجودة 82 تكون عادةً دون 400 كيلوبايت بلا فرق مرئي على الشاشة.
أنشئ lib/storage.ts:
import sharp from 'sharp'
import { createHash, randomUUID } from 'node:crypto'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
const s3 = new S3Client({
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
})
export async function storeImage(image: Buffer, userId: string): Promise<string> {
const webp = await sharp(image)
.webp({ quality: 82, effort: 4 })
.toBuffer()
const key = `generations/${userId}/${randomUUID()}.webp`
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: webp,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000, immutable',
}),
)
return `${process.env.CDN_BASE_URL}/${key}`
}
export function promptFingerprint(input: Record<string, unknown>): string {
return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 32)
}نقطتان جديرتان بالتنبيه. القيمة effort: 4 إعداد وسط مقصود: effort: 6 قد يقتطع 8% إضافية من حجم الملف مقابل ثلاثة أضعاف زمن المعالج تقريبًا، وهي مقايضة سيئة داخل طلب استغرق ثلاثين ثانية أصلًا. وترويسة التخزين immutable آمنة تحديدًا لأن المفتاح يتضمّن معرّفًا فريدًا: البايتات على ذلك الرابط لن تتغيّر أبدًا.
أما promptFingerprint فهي للخطوة التالية.
الخطوة 6: لا تدفع مرتين مقابل الصورة نفسها
توليد الصور غير حتمي، لكن المستخدمين مكرّرون إلى حد بعيد. يعدّلون كلمة واحدة، ولا يعجبهم الناتج، فيعيدون المطالبة الأصلية. تخزين الطلبات المتطابقة مؤقتًا هو أعلى أدوات ضبط التكلفة أثرًا في هذا التطبيق كله.
import { Redis } from '@upstash/redis'
import { promptFingerprint } from './storage'
const redis = Redis.fromEnv()
export async function cachedGeneration(
input: { prompt: string; aspectRatio: string; imageSize: string; tier: string },
produce: () => Promise<string>,
): Promise<{ url: string; cached: boolean }> {
const key = `img:${promptFingerprint(input)}`
const hit = await redis.get<string>(key)
if (hit) return { url: hit, cached: true }
const url = await produce()
await redis.set(key, url, { ex: 60 * 60 * 24 * 7 })
return { url, cached: false }
}اجعل البصمة تشمل مجموعة المعاملات كاملة لا نص المطالبة فقط: الكلمات نفسها بنسبة 16:9 وبنسبة 9:16 صورتان مختلفتان، وذاكرة مؤقتة تتجاهل ذلك ستسلّم المستخدمين نسبة أبعاد خاطئة، وهو خلل يصعب تشخيصه فعلًا.
وثمة تحفّظ يستحق الظهور في واجهتك: إن أراد المستخدم حقًّا قراءةً مختلفة للمطالبة نفسها، فإن الذاكرة المؤقتة تجعل التطبيق يبدو معطّلًا. أضف زر «إعادة التوليد» يُلحِق قيمة عشوائية ببصمة المدخلات ليُخطئ الذاكرة عمدًا.
الخطوة 7: واجهة العميل
الواجهة نموذج إدخال وحالة تحميل وصورة. إجراءات الخادم مع useActionState في React 19 تُبقيها مختصرة. أنشئ app/studio/generate-form.tsx:
'use client'
import { useActionState } from 'react'
import { generateAction } from './actions'
const RATIOS = ['1:1', '4:3', '16:9', '9:16', '21:9'] as const
export function GenerateForm() {
const [state, formAction, pending] = useActionState(generateAction, null)
return (
<div className="grid gap-6 lg:grid-cols-2">
<form action={formAction} className="flex flex-col gap-4">
<textarea
name="prompt"
rows={5}
required
minLength={3}
maxLength={2000}
placeholder="صف الصورة التي تريدها..."
className="w-full resize-y rounded-lg border p-3"
/>
<select name="aspectRatio" defaultValue="1:1" className="rounded-lg border p-2">
{RATIOS.map((ratio) => (
<option key={ratio} value={ratio}>
{ratio}
</option>
))}
</select>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-50"
>
{pending ? 'جارٍ التوليد...' : 'توليد'}
</button>
{state?.error && (
<p role="alert" className="text-sm text-red-600">
{state.error}
</p>
)}
</form>
<div className="flex aspect-square items-center justify-center rounded-lg border bg-neutral-50">
{pending && <span className="animate-pulse text-sm">جارٍ العرض...</span>}
{state?.url && (
<img src={state.url} alt="النتيجة المولَّدة" className="h-full w-full object-contain" />
)}
</div>
</div>
)
}النقطة غير البديهية في إتاحة الوصول: role="alert" على فقرة الخطأ. تصل إخفاقات التوليد بعد عشرين إلى أربعين ثانية من النقر، أي بعد أن يكون مستخدم قارئ الشاشة قد نقل التركيز إلى موضع آخر بوقت طويل. وبلا منطقة حيّة، يمر الإخفاق صامتًا.
امنح حاوية المعاينة نسبة أبعاد ثابتة كذلك. بدونها تتزحزح الصفحة بعنف عند وصول الصورة، وهو ما يُقرأ على اتصال بطيء كتخطيط معطوب.
الخطوة 8: التحرير الحواري بالصور المرجعية
هنا يثبت Nano Banana Pro جدواه أمام نموذج نص-إلى-صورة بسيط. فهو يقبل عدّة صور مرجعية في استدعاء واحد ويستطيع تركيبها مع الحفاظ على اتّساق الموضوع عبر التعديلات.
الخدمة تدعم ذلك مسبقًا عبر references. الجزء المثير للاهتمام هو انضباط المطالبة:
const edited = await generateImage({
prompt: [
'Using the product photo as the subject, place it on a polished walnut surface.',
'Keep the product shape, colour, and label text exactly as in the reference.',
'Change only the background and lighting: soft window light from the left, shallow depth of field.',
].join(' '),
references: [{ data: productBase64, mimeType: 'image/jpeg' }],
aspectRatio: '4:3',
imageSize: '2K',
})بنية الجمل الثلاث — الموضوع، وما يجب ألّا يتغيّر، وما ينبغي تغييره — أكثر موثوقية بكثير من جملة واحدة سلسة. تنجرف نماذج الصور في الخصائص غير المقيَّدة؛ وتسمية الثوابت صراحةً هو ما يثبّتها.
وللتحرير متعدد الجولات، أعد إدخال المخرَج السابق كمرجع للطلب التالي:
let current = initialResult.image
for (const instruction of ['make the lighting cooler', 'add a subtle reflection']) {
const next = await generateImage({
prompt: `Apply this change and keep everything else identical: ${instruction}`,
references: [{ data: current.toString('base64'), mimeType: 'image/png' }],
})
if (!next.ok) break
current = next.image
}انتبه إلى أن الجودة تتدهور في سلاسل التحرير الطويلة: كل تمريرة توليد جديد مشروط بسابقه، فتتراكم الشوائب الصغيرة. عمليًّا، أبقِ السلسلة دون خمس خطوات تقريبًا، وأتِح للمستخدم مخرجًا للعودة إلى الصورة الأصلية.
الخطوة 9: النص العربي ومتعدد اللغات داخل الصور
معظم نماذج الصور تنتج عربية تبدو عربية من بعيد وتتهاوى عن قرب: حروف غير موصولة، وتشكيل سابح، وكلمات معكوسة. Nano Banana Pro أفضل في هذا بوضوح من سابقيه، ما يجعله صالحًا فعليًّا للعمل الإبداعي الموجَّه لسوق الشرق الأوسط وشمال أفريقيا — لكن بشرط أن تطالبه بذلك قصدًا.
ما ينجح:
await generateImage({
prompt: [
'A minimal social media banner, deep teal background, geometric Islamic pattern in the corners.',
'Render this exact Arabic headline, large and centred, in a modern Naskh typeface:',
'"نقطة للحلول الرقمية"',
'The text must be fully connected and correctly shaped right-to-left.',
'No other text anywhere in the image.',
].join('\n'),
aspectRatio: '16:9',
imageSize: '2K',
})أربع قواعد تصنع الفارق:
- اقتبس النص حرفيًّا. طلب «عنوان عربي عن الحلول الرقمية» يعطيك هراءً يبدو معقولًا. أعطِ النموذج الحروف نفسها.
- سمِّ سلوك الخط. طلب حروف موصولة ومُشكَّلة من اليمين إلى اليسار صراحةً يقلّل بوضوح إخفاق تفكّك الحروف.
- سمِّ عائلة الخط. «نسخ حديث» أو «كوفي هندسي» يثبّت أشكال الحروف؛ وبدونه يُوسّط النموذج بين الأنماط فيخرج بشيء لا هو هذا ولا ذاك.
- امنع أي نص إضافي. تعشق نماذج الصور اختراع نصوص مساندة، وفي هذه العربية المخترَعة يظهر الهراء.
تحقّق دائمًا من النص المعروض عبر قارئ بشري قبل النشر. وهذا يتضاعف في العربية، حيث تكون الكلمة المشوّهة أو المعكوسة غير مرئية لمراجع لا يقرأ العربية. وإن كنت تبني مسارات عربية أوسع، فإن مزالق التطبيع في درس مسار RAG العربي تنطبق كذلك على نصوص المطالبات.
الخطوة 10: المصدر والسلامة وما تقوله للعميل
كل صورة تنتجها نماذج جوجل للصور تحمل SynthID، وهي علامة مائية غير مرئية مضمَّنة في البكسلات. تصمد أمام ضغط معتدل وقصّ وتعديلات لونية، وهي قابلة للكشف عبر أدوات التحقّق لدى جوجل. ويمكن كذلك سؤال Gemini مباشرةً عمّا إذا كانت صورة مرفوعة تحمل هذه العلامة.
لهذا وزن تجاري لا أخلاقي فحسب. فإن سلّمت عميلًا أصولًا مولَّدة بالذكاء الاصطناعي، فالموقف الأمين أن تخبره بأن الصور مولَّدة آليًّا وتحمل علامة مائية قابلة للكشف. أما اكتشاف ذلك لاحقًا من طرف ثالث فحوار أسوأ بكثير.
خطوتان عمليّتان:
- احفظ إثبات المصدر بجوار الأصل. خزّن معرّف النموذج والمطالبة والطابع الزمني والمستخدم مع كل عملية توليد. حين يسألك أحدهم عن صورة بعد ثمانية أشهر، ستريد استعلام قاعدة بيانات لا حفريات أثرية.
- لا تجرّد البيانات الوصفية بحكم العادة. تحويلك إلى WebP عبر
sharpيزيل EXIF افتراضيًّا أصلًا. هذا جيد للخصوصية، لكنه يعني أن سجلّ قاعدة بياناتك هو سجلّ المصدر الوحيد الذي تتحكّم به — فتعامل معه على هذا الأساس.
أما السلامة: يتحكّم personGeneration داخل imageConfig في إمكان توليد أشخاص، وتختلف السياسة بحسب المنطقة. اضبطه صراحةً بدل الاتّكال على القيمة الافتراضية، واقرن مرشّح النموذج بفحوصك على مستوى المطالبة. والنهج الطبقي في دليل ضوابط وكلاء الذكاء الاصطناعي ينتقل إلى هنا مباشرة: مرشّحات النموذج تلتقط الحالات الواضحة، وقواعد التطبيق تلتقط ما يخالف سياستك أنت فقط.
اختبار التنفيذ
تحقّق من كل جزء على حدة بدل التنقّل في الواجهة على أمل أن ينجح كل شيء.
طبقة الخدمة. اختبر الاتحاد كاملًا لا المسار السعيد وحده:
import { describe, expect, it } from 'vitest'
import { generateImage } from '@/lib/gemini-image'
describe('generateImage', () => {
it('returns an image buffer for a benign prompt', async () => {
const result = await generateImage({ prompt: 'a red ceramic bowl', imageSize: '1K' })
expect(result.ok).toBe(true)
if (result.ok) expect(result.image.length).toBeGreaterThan(1000)
}, 60_000)
it('reports blocked prompts without throwing', async () => {
const result = await generateImage({ prompt: PROMPT_KNOWN_TO_BE_BLOCKED })
expect(result.ok).toBe(false)
}, 60_000)
})صنّف هذه كاختبارات تكامل: فهي تكلّف مالًا حقيقيًّا في كل تشغيل، ولا مكان لها في مسار طلبات الدمج. شغّلها وفق جدول زمني بدلًا من ذلك.
المسار. حاكِ generateImage واختبر ما هو منطق خالص: هل يتلقّى الطلب غير المُوثَّق رمز 401 قبل أي استدعاء للنموذج، وهل تحصل جلسة الخطة المجانية على tier: 'fast'، وهل تُرفَض مصفوفة مراجع مفرطة الحجم. هذه سريعة ومجانية ومكانها التكامل المستمر. ويغطّي دليل Vitest وTesting Library إعداد المحاكاة.
الميزانية. الفحص الذي ينساه الجميع: ولّد خمسين صورة عبر نقطة النهاية الحقيقية، ثم قارن لوحة مزوّدك بما توقّعت إنفاقه. إن اختلف الرقمان، فاعرف السبب قبل الإطلاق لا بعده.
استكشاف الأخطاء وإصلاحها
«أعاد النموذج نصًّا بدل صورة». غالبًا responseModalities: ['IMAGE'] مفقود، أو مطالبة فهمها النموذج سؤالًا. المطالبات المصاغة كتعليمات («ملصق يعرض...») تتفوّق على الأسئلة («هل يمكنك صنع...»).
صور فارغة أو مبتورة بدقة 4K. عادةً مهلة زمنية لا فشل في النموذج. ارفع maxDuration، وتحقّق مما إذا كانت استضافتك تحدّ زمن الاستجابة بمعزل عن إعداد إطار العمل.
تجاهل نسبة الأبعاد. تأكّد أن aspectRatio داخل imageConfig لا في المستوى الأعلى من config. فوضعه في المكان الخطأ يُسقِطه بصمت، وهي عشر دقائق تشخيص مزعجة بحق.
رفض الصورة المرجعية. احذف البادئة data:image/png;base64, قبل الإرسال. يتوقّع inlineData.data حمولة base64 الخام فقط، ولن تقوم الحزمة بذلك نيابةً عنك.
تكاليف أعلى من المتوقّع. افحص ثلاثة أمور بالترتيب: نسبة إصابة الذاكرة المؤقتة، وهل قيمة imageSize الافتراضية أكبر مما قصدت، وهل منطق إعادة المحاولة يعيد التوليد بصمت عند الأخطاء العابرة. الثالث هو المتّهم المعتاد: غلاف إعادة محاولة ساذج يحوّل توليد 4K فاشلًا واحدًا إلى ثلاث عمليات محاسَب عليها.
نص عربي مشوّه. أعد قراءة الخطوة 9. وفي الحالات العنيدة، ولّد الصورة بلا نص وركّب الطباعة عبر sharp أو طبقة SVG — فعرض نص حتمي أفضل من نموذج موثوق بنسبة 90% حين يكون العميل هو من يقرأ الكلمات.
الخطوات التالية
- أضف تقدّمًا متدفّقًا. العمليات الطويلة تبدو معطّلة بلا تغذية راجعة. قناة أحداث مُرسَلة من الخادم تُبلّغ عن حالات الانتظار والتوليد والتخزين تكلّف قليلًا وتغيّر الزمن المُدرَك كثيرًا.
- ضع العمل في طابور. بعد عدد قليل من المستخدمين المتزامنين، انقل التوليد خارج مسار الطلب إلى مهمة خلفية. ويغطّي درس Trigger.dev v4 نمط التنفيذ الدائم اللازم لذلك.
- راقب واقِس. المطالبة والنموذج وزمن الاستجابة والتكلفة والنتيجة لكل عملية توليد، في أثر واحد. ويتعامل Langfuse مع استدعاءات نماذج الصور كما يتعامل مع النصية.
- قارن المزوّدين. التوجيه عبر بوّابة يتيح تبديل النماذج دون لمس كود التطبيق؛ راجع دليل بوّابة الذكاء الاصطناعي.
- وسّع الاستوديو. التوليد الجماعي من ملف CSV للمطالبات، وقوالب هوية بصرية تُضيف أسلوب البيت تلقائيًّا، ومعرض بسجلّ إعادة التوليد — هذه أول ثلاث ميزات يطلبها المستخدمون.
الخاتمة
الفجوة بين عرض تجريبي لتوليد الصور ومنتج لتوليد الصور ليست في جودة النموذج، بل في كل ما يحيط باستدعائه. نوع مُعاد مُميَّز يعامل رفض السلامة كبيانات. واختيار الفئة على الخادم كي لا يرقّي العميل نفسه. وتحويل إلى WebP قبل التخزين. وذاكرة مؤقتة ببصمة تمنع الدفع مرتين مقابل الصورة نفسها. وبنية مطالبة صريحة لعرض النص، وإثبات مصدر أمين عند التسليم.
Nano Banana Pro نموذج قوي فعلًا، خاصة في التركيب عبر الصور المرجعية والطباعة متعددة اللغات — والحالة العربية وحدها تفتح أعمالًا لم تكن عملية قبل ثمانية عشر شهرًا. لكن النموذج هو الجزء الأرخص إتقانًا في هذا النظام. الجزء الأغلى هو السباكة المحيطة به، وهي الآن بين يديك.