Crea marcas de agua geolocalizadas en imágenes con Transloadit

Una foto puede contener coordenadas GPS en sus metadatos EXIF. Podemos leer esas coordenadas de forma local, pedirle a un geocodificador inverso una etiqueta de lugar y luego usar Transloadit para añadir esa etiqueta a la imagen. Leer EXIF y geocodificar de forma inversa son operaciones distintas: un geocodificador no extrae metadatos de imagen.

Antes de empezar
Usa Node.js 24 o una versión más reciente y un JPEG de tu propiedad que contenga metadatos GPS. Este ejemplo, a propósito, no promete soporte para todas las variantes RAW de cámara o HEIC. Instala las bibliotecas reales:
npm install exifr@7.1.3 @transloadit/node@4.12.0
Guarda el lockfile resultante junto con tu proyecto. El código es una CLI para una sola foto, no un servicio público. Envía coordenadas al servicio público Nominatim y sube la foto a Transloadit. No lo uses con ubicaciones sensibles, fotos sin permiso ni cargas de trabajo masivas y automatizadas.
Lee la política de uso de Nominatim. Indica un contacto identificable, ejecuta solo una instancia a la vez, deja al menos un segundo entre consultas y reutiliza resultados previos en lugar de consultar repetidamente la misma foto. Para un producto o un pipeline por lotes, elige un proveedor cuyo contrato admita esa carga de trabajo o aloja tu propio geocodificador.
Código
Coloca este programa completo en geo-watermarker.mjs. Proporciona TRANSLOADIT_AUTH_KEY,
TRANSLOADIT_AUTH_SECRET, GEOCODER_CONTACT y PHOTO a través de tu entorno local.
GEOCODER_CONTACT debe ser un email de contacto real del operador de la aplicación, no un secreto.
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',
},
},
},
})
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
})
Extracción de información
Exifr lee las etiquetas GPS del JPEG y las convierte en latitud y longitud decimales. Cero es una coordenada válida, por lo que el programa comprueba rangos numéricos en lugar de evaluar si el valor es «truthy». Las coordenadas ausentes detienen el flujo de trabajo antes de cualquier subida.
El búfer acotado se usa tanto para la extracción de metadatos como para la subida, lo que evita una segunda lectura de un archivo que podría haber cambiado. Mantén la foto local inmutable mientras la lees.
Geocodificación
La geocodificación inversa asocia coordenadas con un lugar cartografiado cercano; no es prueba de dónde se tomó una foto. Los datos EXIF pueden faltar, estar editados o ser inexactos. El ejemplo pide una etiqueta de lugar aproximada, pero aun así envía las coordenadas originales al proveedor.
La CLI imprime la atribución de OpenStreetMap. Conserva los avisos de atribución y de licencia obligatorios dondequiera que publiques los datos de lugar derivados. Un mensaje en el terminal por sí solo no es atribución para una galería de imágenes publicada. Revisa la etiqueta devuelta y acórtala tú mismo cuando sea necesario antes de usar este patrón con una fotografía real.
Codificación del resultado final
La Auth Key y el Auth Secret autentican el SDK del lado del servidor. No son credenciales de Template para almacenamiento. Nunca pongas el secreto en código de navegador, en un repositorio público ni en una transcripción de terminal compartida.
El Robot /image/resize usa la
dirección como una Assembly Variable y devuelve un JPEG.
La opción strip elimina los metadatos de origen de la salida, pero el original subido y la
petición al geocodificador ya han expuesto la ubicación a esos servicios. Revisa tus requisitos de
tratamiento de datos antes de ejecutar el programa.
Resultados
Después de configurar tu entorno, ejecuta:
node geo-watermarker.mjs
Una Assembly correcta imprime una URL de resultado temporal y el aviso de atribución. Descarga el resultado cuanto antes o añade un Step de exportación hacia un almacenamiento que tú controles. Revisa la etiqueta renderizada por si se recorta con las dimensiones de tu imagen; el tamaño de fuente fijo es un ejemplo, no un diseño universal.

Esto demuestra el límite de la integración: extracción local de metadatos, una consulta de ubicación explícita y una operación de marca de agua gestionada. Automatizarlo para un producto también exige consentimiento, condiciones de proveedor adecuadas, resultados de consulta conservados, límites operativos y un flujo de trabajo de publicación revisado.