Demo: Descritor de Imagem com Canvas e IA Local
intermediárioDesenhe no canvas do navegador e peça à IA para descrever seu desenho. Demonstra input multimodal (imagem + texto) via Prompt API com Gemini Nano on-device.
Visão Geral
Demo que transforma o navegador numa prancheta inteligente: você desenha no canvas e a IA descreve o que vê. Usa a Prompt API com input multimodal — o desenho vira um blob de imagem enviado junto com o prompt de texto pro Gemini Nano, tudo rodando on-device.
Pra quem: Desenvolvedores explorando capacidades multimodais da Prompt API (imagem + texto).
Técnica principal: canvas.toBlob() convertendo o desenho em imagem, enviada como input multimodal via LanguageModel.prompt() com conteúdo misto [{type: "image"}, {type: "text"}].
Flag necessária: chrome://flags/#prompt-api-for-gemini-nano-multimodal-input
Wireframe
┌─────────────────────────────────────────────────────┐
│ 🎨 Descritor de Imagem com Canvas │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─ Toolbar ──────────────────────────────────────┐ │
│ │ ✏️ Lápis │ 🔴🟢🔵⚫ │ [Espessura ▾] │ 🗑️ Limpar│ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Canvas (480×360) │ │
│ │ área de desenho livre │ │
│ │ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ [ 🔍 Descrever Desenho ] │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ "Parece ser um gato sentado, com orelhas │ │
│ │ triangulares e bigodes. O traço é simples, │ │
│ │ feito à mão livre com linhas pretas." │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ⚠️ Requer Chrome 138+ com flag multimodal ativa │
└─────────────────────────────────────────────────────┘
HTML
<section class="demo-container" id="canvas-descriptor">
<h2>🎨 Descritor de Imagem com Canvas</h2>
<div id="status-bar" class="status" hidden>
<span id="status-message"></span>
</div>
<div class="toolbar">
<button id="tool-pencil" class="tool active" title="Lápis">✏️</button>
<div class="color-picker">
<button class="color-btn active" data-color="#000000" style="background:#000"></button>
<button class="color-btn" data-color="#dc2626" style="background:#dc2626"></button>
<button class="color-btn" data-color="#16a34a" style="background:#16a34a"></button>
<button class="color-btn" data-color="#2563eb" style="background:#2563eb"></button>
</div>
<select id="stroke-width">
<option value="2">Fina</option>
<option value="5" selected>Média</option>
<option value="10">Grossa</option>
</select>
<button id="btn-clear" title="Limpar canvas">🗑️ Limpar</button>
</div>
<canvas id="draw-canvas" width="480" height="360"></canvas>
<div class="actions">
<button id="btn-describe" disabled>🔍 Descrever Desenho</button>
</div>
<div id="result" class="result-box" hidden>
<h3>Descrição da IA:</h3>
<p id="description-output"></p>
</div>
</section>
Código JavaScript
class CanvasDescriptor {
constructor() {
this.canvas = document.getElementById("draw-canvas");
this.ctx = this.canvas.getContext("2d");
this.btnDescribe = document.getElementById("btn-describe");
this.btnClear = document.getElementById("btn-clear");
this.resultEl = document.getElementById("result");
this.outputEl = document.getElementById("description-output");
this.statusBar = document.getElementById("status-bar");
this.statusMessage = document.getElementById("status-message");
this.strokeSelect = document.getElementById("stroke-width");
this.isDrawing = false;
this.currentColor = "#000000";
this.hasContent = false;
this.session = null;
this.setupCanvas();
this.setupToolbar();
this.init();
}
setupCanvas() {
// Fundo branco
this.ctx.fillStyle = "#ffffff";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Configuração padrão do traço
this.ctx.lineCap = "round";
this.ctx.lineJoin = "round";
// Eventos de desenho (mouse)
this.canvas.addEventListener("mousedown", (e) => this.startDraw(e));
this.canvas.addEventListener("mousemove", (e) => this.draw(e));
this.canvas.addEventListener("mouseup", () => this.stopDraw());
this.canvas.addEventListener("mouseleave", () => this.stopDraw());
// Eventos de desenho (touch)
this.canvas.addEventListener("touchstart", (e) => {
e.preventDefault();
this.startDraw(e.touches[0]);
});
this.canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
this.draw(e.touches[0]);
});
this.canvas.addEventListener("touchend", () => this.stopDraw());
}
setupToolbar() {
// Cores
document.querySelectorAll(".color-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelector(".color-btn.active")?.classList.remove("active");
btn.classList.add("active");
this.currentColor = btn.dataset.color;
});
});
// Limpar
this.btnClear.addEventListener("click", () => this.clearCanvas());
// Descrever
this.btnDescribe.addEventListener("click", () => this.describe());
}
getPos(e) {
const rect = this.canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
startDraw(e) {
this.isDrawing = true;
const pos = this.getPos(e);
this.ctx.beginPath();
this.ctx.moveTo(pos.x, pos.y);
}
draw(e) {
if (!this.isDrawing) return;
const pos = this.getPos(e);
this.ctx.strokeStyle = this.currentColor;
this.ctx.lineWidth = parseInt(this.strokeSelect.value);
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.hasContent = true;
}
stopDraw() {
this.isDrawing = false;
this.ctx.closePath();
}
clearCanvas() {
this.ctx.fillStyle = "#ffffff";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.hasContent = false;
this.resultEl.hidden = true;
}
async init() {
if (!("LanguageModel" in window)) {
this.showStatus("❌ Prompt API não disponível. Use Chrome 138+.", "error");
return;
}
const availability = await LanguageModel.availability();
if (availability === "unavailable") {
this.showStatus("❌ Modelo não disponível neste dispositivo.", "error");
return;
}
if (availability === "downloading") {
this.showStatus("⏳ Baixando modelo... Aguarde.", "loading");
}
try {
this.session = await LanguageModel.create({
expectedInputs: [
{ type: "image" },
{ type: "text", languages: ["pt", "en"] }
],
expectedOutputs: [{ type: "text", languages: ["pt"] }],
initialPrompts: [{
role: "system",
content: "Você é um assistente visual. Descreva imagens de forma clara e objetiva em português. Se o desenho for abstrato, descreva formas, cores e padrões observados."
}],
monitor(m) {
m.addEventListener("downloadprogress", (e) => {
const pct = Math.round(e.loaded * 100);
document.getElementById("status-message").textContent =
`⏳ Baixando modelo... ${pct}%`;
});
}
});
this.btnDescribe.disabled = false;
this.hideStatus();
} catch (err) {
this.showStatus(`❌ Erro ao criar sessão: ${err.message}`, "error");
}
}
async describe() {
if (!this.hasContent) {
this.showStatus("⚠️ Desenhe algo no canvas primeiro.", "error");
setTimeout(() => this.hideStatus(), 2000);
return;
}
this.btnDescribe.disabled = true;
this.btnDescribe.textContent = "⏳ Analisando...";
this.resultEl.hidden = true;
try {
const blob = await this.canvasToBlob();
const response = await this.session.prompt([
{ type: "image", content: blob },
{ type: "text", content: "Descreva o que você vê neste desenho. Identifique formas, objetos e cores presentes." }
]);
this.outputEl.textContent = response;
this.resultEl.hidden = false;
} catch (err) {
if (err.name === "AbortError") return;
this.showStatus(`❌ Erro: ${err.message}`, "error");
} finally {
this.btnDescribe.disabled = false;
this.btnDescribe.textContent = "🔍 Descrever Desenho";
}
}
canvasToBlob() {
return new Promise((resolve) => {
this.canvas.toBlob((blob) => resolve(blob), "image/png");
});
}
showStatus(msg, type) {
this.statusBar.hidden = false;
this.statusBar.className = `status status-${type}`;
this.statusMessage.textContent = msg;
}
hideStatus() {
this.statusBar.hidden = true;
}
}
document.addEventListener("DOMContentLoaded", () => new CanvasDescriptor());
Fluxo UX
- Página carrega → verifica suporte da Prompt API + flag multimodal
- Modelo disponível → botão “Descrever” habilitado; se downloading, mostra progresso
- Usuário desenha → canvas captura traços com cor e espessura escolhidas
- Clica “Descrever Desenho” → canvas exportado como PNG blob via
toBlob() - Blob enviado como input multimodal →
session.prompt([{type:"image"}, {type:"text"}]) - Resposta chega → descrição exibida no card de resultado
- Limpar → reseta canvas e esconde resultado anterior
Edge Cases e Tratamento de Erros
| Cenário | Tratamento |
|---|---|
Prompt API não existe (window.LanguageModel undefined) | Mensagem + link para requisitos |
| Flag multimodal desativada | Erro de sessão capturado, orienta ativar a flag |
| Canvas vazio (nenhum traço) | Bloqueia envio, alerta amigável temporário |
| Desenho muito simples (1 ponto) | Funciona, IA descreve o que conseguir |
| Blob muito grande (canvas HD) | Canvas fixo em 480×360 mantém blob leve (~50KB) |
| Touch em mobile | Eventos touch mapeados, preventDefault evita scroll |
| Sessão abortada | Silencioso (AbortError ignorado) |
| Modelo sem suporte multimodal | Erro de sessão com mensagem explicativa |
| Múltiplos cliques rápidos | Botão desabilitado durante processamento |
CSS Essencial
#draw-canvas {
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: crosshair;
touch-action: none;
display: block;
max-width: 100%;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.color-picker {
display: flex;
gap: 0.25rem;
}
.color-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
}
.color-btn.active {
border-color: #1f2937;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #1f2937;
}
.tool.active {
background: #dbeafe;
border-color: #2563eb;
}
.result-box {
margin-top: 1rem;
padding: 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.result-box p {
line-height: 1.6;
color: #1f2937;
}
.status-error { color: #dc2626; }
.status-loading { color: #2563eb; }