Chismes del Lince 06: El Ojo del Lince: una IA que reconoce lo que fotografías con el móvil
Leonado, el dron-buitre
Cada noche, cuando el Lince recorre Sierra Bermeja, hay una cosa que no puede hacer por muy afinados que tenga los sentidos: ver lo que queda lejos, arriba o detrás de un peñasco. Para eso tiene a Leonado.
Leonado no es un buitre de verdad. Es un dron con forma de buitre leonado, diseñado a imagen de los que anidan en los cortados de Casares, a un vuelo de aquí. Como el Halcón del Capitán América, Leonado vuela por delante, mira lo que el Lince no alcanza y se lo cuenta al oído. Y como sus primos de carne y pluma, no se le escapa nada.
Ya sé lo que estás pensando: un buitre, el malo de la película. Pues es justo al revés, y Leonado está aquí para arreglar esa injusticia. Un buitre leonado tiene casi tres metros de envergadura, pasa horas planeando sin batir las alas aprovechando las térmicas y detecta desde muy alto lo que ocurre en el suelo. Y hace algo que el monte agradece cada día: limpia. Al retirar animales muertos frena la propagación de enfermedades, y lo hace sin quejarse.
Es de los mejores guardianes de la sierra, y merece un uniforme de superhéroe.
Esta noche el Lince ha visto algo en el monte y no sabe qué es. Leonado hace un pase, le saca una foto, y en pocos segundos se la devuelve con nombre y apellidos.
Así funciona El Ojo del Lince.
Introducción
En los chismes anteriores el móvil escuchaba (Chisme 05), medía la luz (Chisme 04) y charlaba con la Gata Montesa (Chismes 02 y 03).
Ahora le damos ojos.
La idea es sencilla: hacemos una foto con el móvil, la enviamos a una inteligencia artificial capaz de interpretar imágenes y recibimos una respuesta en texto sobre lo que aparece en ella.
La diferencia respecto al chatbot de los chismes anteriores es importante. Hasta ahora mandábamos texto. En este caso, el mensaje contiene también una imagen.
Y aquí empieza lo interesante.
Porque técnicamente mandar una fotografía no es mucho más complicado que mandar una frase. Lo complicado es decidir qué fotografía estamos mandando, a dónde viaja y quién puede llegar a verla.
⚠️ Esta entrada es para el profesorado, no para el alumnado
Este chisme lo hemos escrito para enseñar qué se puede hacer y cómo funciona por dentro. Lo mostramos en clase como demostración, pero no lo vamos a mandar como tarea ni lo va a montar el alumnado.
La razón principal es sencilla: aquí una fotografía sale del dispositivo y viaja a servicios externos.
La foto sale del móvil, pasa por nuestro Worker de Cloudflare y desde allí se envía a Google Gemini para que analice la imagen. Por tanto, estamos trabajando con una fotografía que abandona el dispositivo.
Una foto cuenta más de lo que parece. Además de lo que fotografiamos a propósito, puede aparecer el fondo, ropa, un uniforme, una matrícula, un cuaderno abierto, una pantalla, un cartel del centro o una cara que pasaba por allí.
Si aparecen menores, la situación es todavía más delicada.
Por eso la aplicación incorpora una advertencia y una casilla de confirmación y la regla de esta demo es muy sencilla:
plantas, insectos, piedras y objetos. Nunca personas, caras, documentos ni pantallas.
Además, antes de enviar la imagen la aplicación la redibuja en un
canvasy la convierte a JPEG. Esto permite reducir considerablemente su tamaño y evita conservar los metadatos EXIF de la fotografía original, pero no elimina lo que aparece visualmente dentro de la propia imagen.En un centro educativo, cualquier uso de imágenes de alumnado menor debe revisarse desde el punto de vista de protección de datos y siguiendo las indicaciones del centro y de la persona responsable de protección de datos.
Para trabajar reconocimiento de imágenes sin sacar fotografías del dispositivo seguimos teniendo otras herramientas, como Teachable Machine y LearningML, que permiten trabajar con modelos que funcionan en el propio navegador.
Este Ojo del Lince es, por tanto, una demostración técnica para el profesorado.
Cómo funciona por dentro
El recorrido completo es este:
Móvil → página web → Cloudflare Worker → Gemini → Cloudflare Worker → móvil
La aplicación realiza varios pasos:
- El móvil permite seleccionar una fotografía desde la galería.
- La aplicación comprueba y procesa la imagen.
- La fotografía se reduce a un máximo de 800 píxeles en su dimensión mayor.
- Se vuelve a dibujar en un
canvasy se convierte a JPEG. - El JPEG se transforma en Base64.
- El navegador envía esa imagen al Worker junto con el código de demostración.
- El Worker comprueba que la petición y la imagen cumplen las condiciones previstas.
- El Worker envía la fotografía a Gemini junto con las instrucciones de Leonado.
- Gemini devuelve una respuesta de texto.
- El Worker devuelve esa respuesta al móvil.
- La página muestra el resultado.
Y hemos añadido una cosa importante después de las primeras pruebas:
¿Qué ocurre si Gemini está saturado?
Pues que Leonado no se rinde a la primera.
Durante las pruebas nos encontramos con un 503 UNAVAILABLE: la fotografía llegaba correctamente hasta Gemini, pero el servicio no podía atender la petición en ese momento.
Eso es precisamente uno de los errores para los que Google recomienda utilizar reintentos con retirada exponencial (exponential backoff).
Por eso el Worker actual vuelve a intentarlo cuando recibe errores temporales como 408, 429 o 5xx, aumentando progresivamente el tiempo de espera.
La idea es:
Petición → 503 → espera → reintento → espera mayor → reintento
Pero con un número máximo de intentos. No queremos crear un bucle infinito ni bombardear la API.
Y hay otra mejora importante: si finalmente no puede responder, el error se devuelve a la página.
Ya no tenemos simplemente:
«No he podido responder.»
Ahora podemos ver en el propio móvil información como:
Servidor/Gemini 503: ...
Eso facilita muchísimo la depuración.
Paso 1: crea un Worker
Tienes la cuenta de Cloudflare y la clave de Gemini del Chisme 03, así que necesitamos un Worker para esta aplicación.
En Cloudflare:
Workers y Pages → Crear → Crear Worker
Puedes llamarlo:
ojo-del-lince
Después lo desplegamos.
Paso 2: guarda las claves como secretos
En la configuración del Worker añade estos dos secretos:
GEMINI_API_KEY
Aquí va la clave de la API de Gemini.
CLAVE_DEMO
Es un código que inventamos nosotros y que la aplicación pedirá antes de permitir la consulta.
El código no se escribe dentro del HTML.
Eso es importante.
La aplicación solamente pide que lo introduzcas y se lo manda al Worker. El valor real permanece almacenado como secreto en Cloudflare.
Paso 3: el Worker de Leonado
El Worker es el intermediario entre nuestra página y Gemini.
Su trabajo es relativamente sencillo:
- recibir la fotografía;
- comprobar el código;
- comprobar el tamaño y el tipo de imagen;
- enviarla a Gemini;
- gestionar los errores temporales;
- devolver la respuesta.
El modelo actual que estamos utilizando es:
gemini-3.8-flash
Gemini 3.8 Flash es actualmente un modelo estable de la API y admite imágenes como entrada.
El detalle importante es que el modelo no debería quedar incrustado como una dependencia permanente de esta entrada. Si Google cambia los modelos disponibles en el futuro, podemos cambiar esa línea en el Worker sin tener que rehacer toda la aplicación.

La parte esencial del Worker queda así:
const MODELO = 'gemini-3.8-flash';
const SISTEMA =
'Eres Leonado, el dron-buitre del Lince de la Noche, superhéroe de Sierra Bermeja ' +
'(Estepona, Málaga), del proyecto educativo MonteSTeam. Recibes UNA foto hecha con un móvil. ' +
'Di qué es: nombre común y, si es un ser vivo, también el científico. Responde en español, ' +
'con humor amable y un toque andaluz, en 3 o 4 frases como máximo. Si no estás seguro, dilo ' +
'y da las dos o tres opciones más probables; no te inventes nada. ' +
'Si en la foto aparece una persona o una cara, NO la describas ni la identifiques: responde solo ' +
'"Veo a una persona. Por privacidad no la describo. Prueba con una planta, un insecto o una piedra." ' +
'No pidas ni guardes datos personales.';
La petición que llega a Gemini contiene dos elementos:
{text: '¿Qué hay en esta foto?'}
y:
{
inline_data: {
mime_type: mime,
data: imagen
}
}
Es decir: una pregunta + una imagen.
Eso es todo lo que necesita Leonado para empezar a trabajar.
Paso 4: ¿por qué necesitamos el Worker?
Podríamos intentar llamar directamente a Gemini desde el navegador.
No debemos hacerlo.
La clave de Gemini no debe aparecer en el HTML público.
Por eso tenemos:
Navegador
↓
Cloudflare Worker
↓
Gemini
La página solamente conoce la dirección del Worker.
La clave de API solamente la conoce el Worker.
Además, tenemos el CLAVE_DEMO para evitar que cualquiera que encuentre la dirección del Worker pueda utilizarlo sin más.
Esto no convierte al Worker en una fortaleza inexpugnable, pero sí añade una capa básica de control apropiada para esta pequeña demostración.
También conviene vigilar el consumo de la API y, si la demo va a crecer, utilizar las herramientas de limitación de peticiones disponibles en Cloudflare.
Paso 5: la página web
La página del Ojo del Lince tiene cuatro elementos fundamentales:
- Una zona para mostrar la fotografía.
- Un campo para introducir el código de demostración.
- Una casilla donde confirmamos que no hay personas ni caras.
- El botón Preguntar a Leonado.
Cuando seleccionamos la fotografía, JavaScript hace el procesamiento.
No enviamos directamente el archivo original.
Primero lo reducimos:
const MAX = 800;
Después lo dibujamos en un canvas y lo convertimos en JPEG:
canvas.toDataURL('image/jpeg', 0.7);
De esta forma la imagen que viaja hasta el Worker es mucho más pequeña.
Paso 6: ahora también podemos ver los errores
Esta es una de las mejoras más útiles respecto a la primera versión.
Antes teníamos algo parecido a:
if (!r.ok) throw new Error('fallo del worker');
Y si algo salía mal, el usuario solamente veía:
No he podido responder.
Eso está muy bien para una aplicación terminada, pero es bastante malo cuando estamos desarrollándola.
Ahora el navegador recoge la respuesta del Worker y muestra el detalle.
Por ejemplo:
Servidor/Gemini 503: Gemini está temporalmente sin capacidad para atender la petición.
O:
Servidor/Gemini 400: …
O:
Autorización: Código incorrecto.
Así podemos distinguir rápidamente entre:
- problema al seleccionar la fotografía;
- problema con el código;
- problema de conexión;
- problema del Worker;
- problema de Gemini;
- saturación temporal de Gemini.
Durante el desarrollo esto nos ha permitido descubrir algo importante: la fotografía sí estaba llegando correctamente a Gemini. El problema que veíamos era un 503 del servicio, no un fallo del móvil al enviar la imagen.
Paso 7: súbelo y pruébalo con el móvil
Una vez desplegado el Worker, copiamos su dirección.
Será algo parecido a:
https://ojo-del-lince.tu-usuario.workers.dev
En el HTML solamente tenemos que poner esa dirección:
const WORKER =
'https://ojo-del-lince.tu-usuario.workers.dev';
El resto de la configuración permanece en el Worker.
Después subimos la página y la abrimos desde el móvil.
Buenas pruebas
Podemos probar con:
- una flor;
- una hoja;
- una piedra;
- un insecto;
- una planta;
- un objeto cotidiano.
También es interesante probar fotografías difíciles:
- una imagen desenfocada;
- algo visto desde lejos;
- una fotografía con poca luz;
- una especie parecida a otra.
Ahí es donde podemos comprobar si Leonado reconoce su incertidumbre o se inventa una respuesta.
Y precisamente una de las instrucciones que le hemos dado es:
Si no estás seguro, dilo y da las dos o tres opciones más probables; no te inventes nada.
Lo que hemos aprendido con el 503
El primer fallo de esta aplicación fue bastante instructivo.
La foto se seleccionaba.
La página funcionaba.
El Worker recibía la petición.
Pero Gemini respondía con:
503 — UNAVAILABLE
Es decir, no teníamos un problema de cámara.
Ni de Base64.
Ni de Cloudflare.
Ni de que el móvil no mandase la fotografía.
Era un problema temporal de disponibilidad del servicio.
Esto es precisamente lo interesante de construir estas cosas en lugar de limitarse a utilizarlas.
Cuando algo falla, podemos seguir el recorrido:
¿Se seleccionó la foto?
↓
¿Se procesó?
↓
¿Se envió al Worker?
↓
¿El Worker recibió la petición?
↓
¿La clave funcionó?
↓
¿Gemini respondió?
↓
¿La respuesta volvió al móvil?
La programación deja de ser magia y se convierte en una cadena de piezas que podemos comprobar una por una.
¿Y la privacidad?
Aquí está la parte más importante del chisme.
La aplicación funciona.
Pero que algo funcione no significa que debamos utilizarlo para cualquier cosa.
La fotografía sale del móvil.
Eso significa que tenemos que pensar antes de pulsar el botón.
Una fotografía aparentemente inocente puede contener:
- una persona al fondo;
- una cara;
- una matrícula;
- un nombre;
- un documento;
- una pantalla;
- información del centro;
- la ubicación visible en la propia imagen.
Por eso la aplicación está pensada como demostración del profesorado.
La regla sigue siendo:
Plantas, insectos, piedras y objetos.
Nunca personas, caras, documentos ni pantallas.
Además, la aplicación redibuja la fotografía antes de enviarla, lo que permite eliminar los metadatos EXIF de la imagen original. Pero eso no significa que la fotografía deje de contener información visual.
Una imagen de una persona sigue siendo una imagen de una persona aunque hayamos eliminado sus metadatos.
¿Y si queremos hacerlo con el alumnado?
Aquí está la frontera interesante.
Para enseñar qué puede hacer una IA multimodal, esta demo es estupenda.
Para que el alumnado empiece a mandar fotografías personales a un servicio externo, la cosa cambia completamente.
Por eso tenemos otra vía.
Con herramientas como Teachable Machine o LearningML podemos construir modelos de reconocimiento que funcionan en el propio navegador y utilizar imágenes de trabajo sin necesidad de enviar las fotografías a Gemini.
Eso nos permite plantear una actividad diferente:
¿Podemos enseñar a una máquina a reconocer especies, objetos o materiales sin sacar nuestras imágenes del dispositivo?
Y ahí ya no estamos solamente utilizando inteligencia artificial.
Estamos hablando de datos, privacidad, entrenamiento, clasificación, errores, sesgos y ciencia.
Eso empieza a parecerse bastante más a lo que queremos trabajar en MonteSTeam.

El siguiente chisme
Leonado ya tiene ojos.
Lo siguiente sería darle voz.
Si conseguimos combinar:
cámara + visión artificial + voz
podemos empezar a imaginar algo mucho más interesante que un simple identificador de plantas.
Por ejemplo, un asistente que describa lo que tiene delante una persona con baja visión.
Pero ahí aparece otra pregunta:
¿Y si no queremos enviar la fotografía a ningún servidor?
Entonces cambia el problema.
Necesitamos estudiar modelos que puedan funcionar localmente en el dispositivo.
Y eso abre otra puerta:
IA local, modelos pequeños, procesamiento en el propio dispositivo y privacidad desde el diseño.
Ese será otro chisme.
Conclusión
Con muy poco cambio respecto al chatbot del Chisme 03 hemos pasado de una IA que lee a una IA que ve.
La parte técnica resulta bastante asequible:
foto → navegador → Worker → Gemini → respuesta
Pero la parte verdaderamente interesante no está en conseguir que funcione.
Está en preguntarnos:
¿Qué datos estamos enviando?
¿A dónde los estamos enviando?
¿Quién puede acceder a ellos?
¿Qué ocurre si la IA se equivoca?
¿Necesitamos realmente mandar esa fotografía fuera del dispositivo?
Por eso este chisme se queda, de momento, como demostración del profesorado.
Y para Technovation nos deja una idea con bastante recorrido: si algún día conseguimos unir la cámara y la voz de Leonado, podríamos explorar herramientas de asistencia para personas con baja visión.
Mientras tanto, Leonado seguirá sobrevolando Sierra Bermeja.
Y la gaviota, de momento, se queda esperando su turno.
⚠️ ANTES DE PUBLICAR NADA, LEE ESTO
Una IA puede equivocarse. Puede confundir una planta con otra y decirlo con total seguridad. Nunca uses esta aplicación para decidir si algo se puede comer, tocar o recoger. Contrasta siempre con una guía fiable o con una persona experta.
Una IA no entiende una fotografía exactamente como lo hace una persona. Reconoce patrones a partir de los datos con los que ha sido desarrollada y puede acertar mucho… y fallar de forma sorprendente.
No fotografíes personas, caras, pantallas ni documentos. Ni las tuyas ni las de nadie.
La fotografía sale del móvil y se envía a servicios externos. Piensa siempre que aquello que fotografías puede acabar fuera de tu control.
Tu clave de API y tu código de demostración son tuyos. No los compartas, no los subas a un repositorio público y no los escribas en el HTML.
Si sospechas que la clave de API se ha filtrado, genera una nueva. Si se ha filtrado el código de demostración, cámbialo en el secreto
CLAVE_DEMO.Esta aplicación es un experimento educativo para demostración del profesorado. No es un producto terminado.
Antes de utilizar herramientas de este tipo con menores, revisa las condiciones del servicio y las instrucciones del centro y de la persona responsable de protección de datos.
La tecnología es la parte fácil. Decidir qué hacemos con ella es el verdadero chisme.
HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#7a2e1d">
<title>Chisme 06 · El Ojo del Lince</title>
<style>
:root{--bg:#f1e8e0;--card:#fbf7f3;--ink:#2e1d17;--mute:#7a6558;--line:#dccbbd;--pine:#7a2e1d;--sea:#a2422a;--ocre:#b9770e;--bad:#8a1c1c}
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
header{background:var(--pine);color:#fff5ee;padding:18px}
header h1{margin:0;font-size:1.4rem;letter-spacing:-.01em}
header p{margin:4px 0 0;color:#f0cdbf;font-size:.9rem}
main{max-width:640px;margin:0 auto;padding:14px}
.aviso{background:#f6e3df;border:1px solid #dfb1a8;color:var(--bad);border-radius:10px;padding:10px 13px;font-size:.9rem;margin:0 0 14px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px;margin:0 0 12px}
#vista{display:none;width:100%;max-height:320px;object-fit:contain;border-radius:8px;background:#e6d9cd;margin:0 0 10px}
label.chk{display:flex;gap:10px;align-items:flex-start;font-size:.95rem;margin:0 0 12px}
label.chk input{width:22px;height:22px;flex:none;margin-top:2px}
.fila{display:flex;gap:8px;flex-wrap:wrap}
button,.btn{font:inherit;font-weight:600;border:0;border-radius:8px;padding:12px 16px;background:var(--pine);color:#fff;min-height:46px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
button:disabled{opacity:.5;cursor:default}
button:focus-visible,.btn:focus-within,input:focus-visible{outline:3px solid var(--ocre);outline-offset:2px}
.btn-input-contenedor {
position: relative;
display: inline-block;
overflow: hidden;
}
.btn-input-contenedor input[type=file] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
font-size: 0;
}
#res{white-space:pre-wrap;overflow-wrap:anywhere}
#res.pens{color:var(--mute);font-style:italic}
#res.err{color:var(--bad)}
input[type=password]{width:100%;font:inherit;padding:12px;border:1.5px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);margin:0 0 12px}
small{display:block;color:var(--mute);font-size:.78rem;margin-top:10px}
</style>
</head>
<body>
<header>
<h1>El Ojo del Lince</h1>
<p>Leonado, el dron-buitre, te dice qué hay en la foto</p>
</header>
<main>
<p class="aviso"><strong>Solo para demostración del profesorado.</strong> La foto sale de tu móvil hacia servidores de Cloudflare y Google. Fotografía únicamente plantas, insectos, piedras u objetos. Nunca personas, caras, documentos ni pantallas.</p>
<div class="card">
<img id="vista" alt="Vista previa de la foto seleccionada">
<input id="cod" type="password" autocomplete="off" placeholder="Código de la demo" aria-label="Código de la demo">
<label class="chk"><input type="checkbox" id="ok"><span>Confirmo que en la foto no hay personas ni caras.</span></label>
<div class="fila">
<div class="btn-input-contenedor">
<span class="btn">Elegir foto de la galería</span>
<input id="foto" type="file" accept="image/*">
</div>
<button id="env" type="button" disabled>Preguntar a Leonado</button>
</div>
</div>
<div class="card" id="cres" hidden>
<div id="res" aria-live="polite"></div>
</div>
<div class="card" id="diag" hidden>
<strong>Diagnóstico</strong>
<div id="diagtxt" style="white-space:pre-wrap;overflow-wrap:anywhere;margin-top:6px;font-size:.82rem"></div>
</div>
<small>Responde una IA y puede equivocarse. Contrasta siempre con una fuente fiable.</small>
</main>
<script>
const WORKER = 'https://ojo-del-lince.jluserotrabajo.workers.dev';
const $ = id => document.getElementById(id);
let jpeg = null;
function actualizar() { $('env').disabled = !(jpeg && $('ok').checked &&$('cod').value); }
function mostrar(txt, clase) {
$('cres').hidden = false;
$('res').className = clase || '';
$('res').textContent = txt;
diagnostico(txt);
}
function diagnostico(txt) {
$('diag').hidden = false;
$('diagtxt').textContent =
`Navegador: ${navigator.userAgent}\n` +
`Worker: ${WORKER}\n` +
`Foto preparada: ${jpeg ? 'sí' : 'no'}\n` +
`Estado: ${txt}`;
}
/* Procesamiento ultra eficiente por HW (createImageBitmap) */
async function reducir(file) {
// 1. Intentar decodificación directa por hardware (móviles modernos)
if ('createImageBitmap' in window) {
try {
const bmp = await createImageBitmap(file);
const MAX = 800;
let w = bmp.width;
let h = bmp.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round((h * MAX) / w); w = MAX; }
else { w = Math.round((w * MAX) / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(bmp, 0, 0, w, h);
bmp.close(); // Libera la foto gigante de la memoria inmediatamente
return canvas.toDataURL('image/jpeg', 0.7);
} catch (e) {
console.warn('Fallback a Image ObjectURL', e);
}
}
// 2. Fallback mediante Blob ObjectURL
return new Promise((resolve, reject) => {
const blobUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const MAX = 800;
let w = img.width;
let h = img.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round((h * MAX) / w); w = MAX; }
else { w = Math.round((w * MAX) / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(blobUrl);
resolve(canvas.toDataURL('image/jpeg', 0.7));
} catch (err) {
URL.revokeObjectURL(blobUrl);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error('No se pudo procesar el formato de esta imagen.'));
};
img.src = blobUrl;
});
}
$('foto').onchange = async e => {
const file = e.target.files[0];
if (!file) return;
mostrar('Procesando foto seleccionada…', 'pens');
try {
const dataUrl = await reducir(file);
jpeg = dataUrl.split(',')[1];
diagnostico(`Foto procesada correctamente. Base64: ${jpeg.length.toLocaleString()} caracteres.`);
$('vista').src = dataUrl;
$('vista').style.display = 'block';$('cres').hidden = true;
} catch (err) {
jpeg = null;
$('vista').style.display = 'none';
mostrar('Error al cargar la foto: ' + (err.message || 'Error de memoria en el navegador.'), 'err');
}
e.target.value = '';
actualizar();
};
$('ok').onchange = actualizar;
$('cod').oninput = actualizar;
$('env').onclick = async () => {$('env').disabled = true;
mostrar('Leonado está analizando la foto…', 'pens');
try {
const r = await fetch(WORKER, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({imagen: jpeg, mime: 'image/jpeg', codigo: $('cod').value}),
});
if (r.status === 401) { mostrar('Código de la demo incorrecto.', 'err'); actualizar(); return; }
if (r.status === 403) { mostrar('Acceso no permitido.', 'err'); actualizar(); return; }
if (!r.ok) {
const msg = await r.text().catch(() => '');
let detalle = msg;
try { const datosError = JSON.parse(msg); detalle = datosError.detalle || msg; } catch (_) {}
throw new Error(`Servidor/Gemini ${r.status}: ${detalle}`);
}
const datos = await r.json();
if (datos.error) throw new Error(`${datos.etapa || 'Servidor'}: ${datos.detalle || 'Error desconocido'}`);
mostrar((datos.texto || '').trim() || 'Leonado se ha quedado sin palabras.', 'ok');
} catch (err) {
mostrar(`Error al consultar: ${err.message}`, 'err');
}
actualizar();
};
</script>
</body>
</html>
WORKER
const MODELOS = [
'gemini-3.8-flash',
'gemini-3.6-flash',
'gemini-3.5-flash'
];
const SISTEMA = 'Eres Leonado, el dron-buitre del Lince de la Noche, superhéroe de Sierra Bermeja ' +
'(Estepona, Málaga), del proyecto educativo MonteSTeam. Recibes UNA foto hecha con un móvil. ' +
'Di qué es: nombre común y, si es un ser vivo, también el científico. Responde en español, ' +
'con humor amable y un toque andaluz, en 3 o 4 frases como máximo. Si no estás seguro, dilo ' +
'y da las dos o tres opciones más probables; no te inventes nada. ' +
'Si en la foto aparece una persona o una cara, NO la describas ni la identifiques: responde solo ' +
'"Veo a una persona. Por privacidad no la describo. Prueba con una planta, un insecto o una piedra." ' +
'No pidas ni guardes datos personales.';
const MIMES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_BASE64 = 4000000;
const esperar = ms => new Promise(resolve => setTimeout(resolve, ms));
export default {
async fetch(request, env) {
const origenPeticion = request.headers.get('Origin');
const cabeceras = {
'Access-Control-Allow-Origin': origenPeticion || '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
const json = {
...cabeceras,
'Content-Type': 'application/json'
};
// Preflight CORS
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: cabeceras
});
}
if (request.method !== 'POST') {
return new Response(
JSON.stringify({
error: true,
etapa: 'Worker',
detalle: 'Método no permitido'
}),
{
status: 405,
headers: json
}
);
}
// Leer JSON
let cuerpo;
try {
cuerpo = await request.json();
} catch {
return new Response(
JSON.stringify({
error: true,
etapa: 'Worker',
detalle: 'JSON inválido'
}),
{
status: 400,
headers: json
}
);
}
// Comprobar código
if (!env.CLAVE_DEMO || cuerpo.codigo !== env.CLAVE_DEMO) {
return new Response(
JSON.stringify({
error: true,
etapa: 'Autorización',
detalle: 'Código incorrecto'
}),
{
status: 401,
headers: json
}
);
}
const { imagen, mime } = cuerpo;
// Comprobar imagen
if (
typeof imagen !== 'string' ||
!imagen ||
imagen.length > MAX_BASE64 ||
!MIMES.includes(mime)
) {
return new Response(
JSON.stringify({
error: true,
etapa: 'Imagen',
detalle: 'Imagen inválida o demasiado grande'
}),
{
status: 400,
headers: json
}
);
}
let ultimoError = '';
// Probamos los modelos en orden
for (const modelo of MODELOS) {
// Tres intentos por modelo
for (let intento = 1; intento <= 3; intento++) {
try {
const url =
`https://generativelanguage.googleapis.com/v1beta/models/${modelo}:generateContent`;
const r = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': env.GEMINI_API_KEY
},
body: JSON.stringify({
system_instruction: {
parts: [{ text: SISTEMA }]
},
contents: [{
role: 'user',
parts: [
{
text: '¿Qué hay en esta foto?'
},
{
inline_data: {
mime_type: mime,
data: imagen
}
}
]
}]
})
});
// ¡Éxito!
if (r.ok) {
const datos = await r.json();
const texto =
datos?.candidates?.[0]?.content?.parts
?.map(p => p.text)
.join('') || '';
return new Response(
JSON.stringify({
texto,
modelo
}),
{
status: 200,
headers: json
}
);
}
// Error de Gemini
const errorGoogle =
await r.text().catch(() => '');
ultimoError =
`${modelo} → HTTP ${r.status}: ${errorGoogle}`;
// Solo reintentamos errores temporales
const temporal =
r.status === 408 ||
r.status === 429 ||
r.status >= 500;
if (!temporal) {
return new Response(
JSON.stringify({
error: true,
etapa: 'Gemini API',
modelo,
status: r.status,
detalle: errorGoogle || 'Error no temporal'
}),
{
status: 502,
headers: json
}
);
}
// Si todavía quedan intentos:
if (intento < 3) {
// 2 s → 4 s
const espera = 2000 * Math.pow(2, intento - 1);
await esperar(espera);
}
} catch (error) {
ultimoError =
`${modelo} → Error de red: ${error.message}`;
// Reintento
if (intento < 3) {
const espera = 2000 * Math.pow(2, intento - 1);
await esperar(espera);
}
}
}
}
// Todos los modelos han fallado
return new Response(
JSON.stringify({
error: true,
etapa: 'Gemini API',
status: 503,
detalle:
'Leonado ha probado varios intentos y modelos, pero Gemini sigue saturado.',
ultimo_error: ultimoError
}),
{
status: 503,
headers: json
}
);
}
};



