Crie marcas-d’água de geolocalização em imagens com a Transloadit

Uma foto pode conter coordenadas GPS nos metadados EXIF. Podemos ler essas coordenadas localmente, pedir a um geocodificador reverso o nome de um local e depois usar a Transloadit para adicionar esse nome à imagem. Ler o EXIF e fazer a geocodificação reversa são operações separadas: um geocodificador não extrai metadados de imagens.

Antes de começar
Use o Node.js 24 ou mais recente e um JPEG seu que contenha metadados GPS. Este exemplo não promete suporte a todas as variantes de RAW de câmera ou HEIC, e isso é intencional. Instale as bibliotecas usadas:
npm install exifr@7.1.3 @transloadit/node@4.12.0
Guarde o lockfile gerado junto com o seu projeto. O código é uma CLI para uma única foto, não um serviço público. Ele envia as coordenadas ao serviço público Nominatim e faz o upload da foto para a Transloadit. Não use locais sensíveis, fotos sem permissão nem cargas de trabalho automatizadas em massa.
Leia a política de uso do Nominatim. Informe um contato que identifique você, execute apenas uma instância por vez, deixe pelo menos um segundo entre as consultas e reaproveite resultados anteriores em vez de consultar a mesma foto várias vezes. Para um produto ou um pipeline em lote, escolha um provedor cujo contrato comporte essa carga de trabalho ou hospede o seu próprio geocodificador.
Código
Coloque este programa completo em geo-watermarker.mjs. Forneça TRANSLOADIT_AUTH_KEY,
TRANSLOADIT_AUTH_SECRET, GEOCODER_CONTACT e PHOTO pelo seu ambiente local.
GEOCODER_CONTACT deve ser um e-mail de contato real de quem opera a aplicação, não um segredo.
import { open } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { Transloadit } from '@transloadit/node'
import exifr from 'exifr'
function required(name) {
const value = process.env[name]
if (!value) throw new Error('Missing configuration.')
return value
}
async function readPhoto(path) {
const handle = await open(path, 'r')
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > 8 * 1024 * 1024) {
throw new Error('Use a JPEG no larger than 8 MiB.')
}
const bytes = Buffer.alloc(stat.size)
let offset = 0
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null)
if (bytesRead === 0) throw new Error('Photo changed while reading.')
offset += bytesRead
}
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error('Use a JPEG photo.')
return bytes
} finally {
await handle.close()
}
}
async function placeFor(latitude, longitude, contact) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) ||
Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
throw new Error('Photo has no valid GPS coordinates.')
}
const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('lat', String(latitude))
url.searchParams.set('lon', String(longitude))
url.searchParams.set('zoom', '10')
url.searchParams.set('accept-language', 'en')
await delay(1000)
const response = await fetch(url, {
headers: { 'User-Agent': 'PhotoWatermarker/1.0 (' + contact + ')' },
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error('Location lookup failed.')
const data = await response.json()
if (typeof data?.display_name !== 'string') throw new Error('No place was found.')
// Keep the label bounded and plain text before sending it to the image renderer.
const label = data.display_name.replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
if (!label || label.length > 160) throw new Error('Place label needs manual review.')
return label
}
async function main() {
const photo = required('PHOTO')
const authKey = required('TRANSLOADIT_AUTH_KEY')
const authSecret = required('TRANSLOADIT_AUTH_SECRET')
const contact = required('GEOCODER_CONTACT')
if (!/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(contact)) {
throw new Error('Provide a contact email for the geocoder.')
}
const bytes = await readPhoto(photo)
const gps = await exifr.gps(bytes)
const address = await placeFor(gps?.latitude, gps?.longitude, contact)
const transloadit = new Transloadit({ authKey, authSecret })
const result = await transloadit.createAssembly({
waitForCompletion: true,
timeout: 120_000,
signal: AbortSignal.timeout(120_000),
uploads: { photo: bytes },
params: {
fields: { address },
steps: {
':original': { robot: '/upload/handle' },
watermarked: {
robot: '/image/resize',
use: ':original',
result: true,
format: 'jpg',
strip: true,
text: [{
text: '${fields.address}',
size: 24,
font: 'Ubuntu',
color: '#ffffff',
background_color: '#000000',
align: 'center',
valign: 'bottom',
y_offset: -12,
}],
imagemagick_stack: 'v3.0.1',
},
},
},
})
const output = result.results?.watermarked?.[0]?.ssl_url
if (result.ok !== 'ASSEMBLY_COMPLETED' || typeof output !== 'string') {
throw new Error('No completed watermarked image was returned.')
}
const url = new URL(output)
if (url.protocol !== 'https:') throw new Error('Unexpected result URL.')
console.log('Result: ' + url.href)
console.log('Place data: OpenStreetMap contributors, https://www.openstreetmap.org/copyright')
}
main().catch(() => {
console.error('Watermarking failed. Check configuration, photo metadata and service availability.')
process.exitCode = 1
})
Extração de informações
O Exifr lê as tags GPS do JPEG e as converte em latitude e longitude decimais. Zero é uma coordenada válida, por isso o programa verifica intervalos numéricos em vez de testar se o valor é verdadeiro ou falso. Se faltarem coordenadas, o fluxo de trabalho para antes de qualquer upload.
O mesmo buffer de tamanho limitado serve para a extração de metadados e para o upload. Assim, um arquivo que poderia ter mudado não precisa ser lido uma segunda vez. Não altere a foto local enquanto ela estiver sendo lida.
Geocodificação
A geocodificação reversa associa coordenadas a um local mapeado próximo. Ela não prova onde uma foto foi tirada. O EXIF pode estar ausente, ter sido editado ou ser impreciso. O exemplo pede um nome de local aproximado, mas mesmo assim envia as coordenadas originais ao provedor.
A CLI imprime a atribuição do OpenStreetMap. Mantenha a atribuição e os avisos de licenciamento exigidos em qualquer lugar onde você publicar os dados de local derivados. Uma mensagem no terminal não basta como atribuição para uma galeria de imagens publicada. Revise o nome retornado e encurte-o você mesmo quando necessário antes de usar esse padrão em uma fotografia real.
Codificação do resultado final
A Auth Key e o Auth Secret autenticam o SDK do lado do servidor. Eles não são Credenciais de Template de armazenamento. Nunca coloque o segredo em código de navegador, em um repositório público ou em uma transcrição de terminal compartilhada.
O Robot /image/resize usa o
endereço como uma Assembly Variable e retorna um JPEG.
A opção strip remove os metadados de origem do arquivo de saída, mas o original enviado e a
requisição ao geocodificador já expuseram a localização a esses serviços. Revise os seus requisitos
de tratamento de dados antes de executar o programa.
Resultados
Depois de configurar o seu ambiente, execute:
node geo-watermarker.mjs
Uma Assembly bem-sucedida imprime uma URL de resultado temporária e o aviso de atribuição. Baixe o resultado logo ou adicione um Step de exportação para um armazenamento que você controla. Verifique se o texto renderizado ficou cortado nas dimensões da sua imagem. O tamanho de fonte fixo é só um exemplo, não um layout que serve para tudo.

Isso mostra onde fica a fronteira da integração: extração local de metadados, uma consulta de localização explícita e uma operação gerenciada de marca-d’água. Para automatizar isso em um produto, também é preciso ter consentimento, termos de provedor adequados, resultados de consulta guardados, limites operacionais e um fluxo de publicação revisado.