Créer un outil de filigrane géolocalisé d’images avec Transloadit

Une photo peut contenir des coordonnées GPS dans ses métadonnées EXIF. Nous pouvons lire ces coordonnées localement, demander un libellé de lieu à un géocodeur inverse, puis utiliser Transloadit pour ajouter ce libellé à l’image. La lecture des EXIF et le géocodage inverse sont des opérations distinctes : un géocodeur n’extrait pas les métadonnées d’image.

Avant de commencer
Utilisez Node.js 24 ou une version plus récente, ainsi qu’un JPEG qui vous appartient et qui contient des métadonnées GPS. Cet exemple ne promet délibérément pas la prise en charge de toutes les variantes RAW d’appareil photo ou HEIC. Installez les bibliothèques réelles :
npm install exifr@7.1.3 @transloadit/node@4.12.0
Conservez le fichier de verrouillage généré avec votre projet. Ce code est une CLI pour une seule photo, pas un service public. Il envoie les coordonnées au service public Nominatim et téléverse la photo vers Transloadit. N’utilisez pas de lieux sensibles, de photos sans autorisation ni de charges de travail automatisées en masse.
Lisez la politique d’utilisation de Nominatim. Fournissez un contact identifiable, n’exécutez qu’une seule instance à la fois, laissez au moins une seconde entre les requêtes et réutilisez les résultats précédents au lieu d’interroger plusieurs fois le service pour la même photo. Pour un produit ou un pipeline de traitement par lots, choisissez un fournisseur dont le contrat prend en charge cette charge de travail ou hébergez votre propre géocodeur.
Code
Placez ce programme complet dans geo-watermarker.mjs. Fournissez TRANSLOADIT_AUTH_KEY,
TRANSLOADIT_AUTH_SECRET, GEOCODER_CONTACT et PHOTO via votre environnement local.
GEOCODER_CONTACT doit être une véritable adresse e-mail de contact de l’opérateur de l’application, pas un secret.
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
})
Extraction des informations
Exifr lit les balises GPS du JPEG et les convertit en latitude et longitude décimales. Zéro est une coordonnée valide ; le programme vérifie donc des plages numériques plutôt que l’évaluation booléenne de la valeur. Des coordonnées manquantes arrêtent le flux de travail avant tout téléversement.
Le tampon de taille limitée sert à la fois à l’extraction des métadonnées et au téléversement, ce qui évite de relire un fichier qui aurait pu changer. Ne modifiez pas la photo locale pendant sa lecture.
Géocodage
Le géocodage inverse associe des coordonnées à un lieu cartographié proche ; ce n’est pas une preuve de l’endroit où une photo a été prise. Les données EXIF peuvent être absentes, modifiées ou inexactes. L’exemple demande un libellé de lieu approximatif, mais il envoie tout de même les coordonnées d’origine au fournisseur.
La CLI affiche l’attribution OpenStreetMap. Conservez les mentions d’attribution et de licence requises partout où vous publiez les données de lieu dérivées. Un simple message dans le terminal ne constitue pas une attribution pour une galerie d’images publiée. Vérifiez le libellé renvoyé et raccourcissez-le vous-même si nécessaire avant d’utiliser ce modèle pour une vraie photographie.
Encodage du résultat final
L’Auth Key et l’Auth Secret authentifient le SDK côté serveur. Ce ne sont pas des informations d’identification de Template pour le stockage. Ne placez jamais le secret dans du code de navigateur, un dépôt public ou une transcription de terminal partagée.
Le Robot /image/resize utilise
l’adresse comme Assembly Variable et renvoie un seul JPEG.
L’option strip supprime les métadonnées source du résultat, mais l’original téléversé et la
requête au géocodeur ont déjà exposé la position à ces services. Examinez vos exigences en matière de
traitement des données avant d’exécuter le programme.
Résultats
Après avoir configuré votre environnement, exécutez :
node geo-watermarker.mjs
Une Assembly réussie affiche une URL de résultat temporaire et la mention d’attribution. Téléchargez rapidement le résultat ou ajoutez un Step d’exportation vers un stockage que vous contrôlez. Vérifiez que le libellé rendu n’est pas tronqué selon les dimensions de votre image ; la taille de police fixe est un exemple, pas une mise en page universelle.

Cet exemple illustre la frontière d’intégration : extraction locale des métadonnées, recherche de lieu explicite et opération de filigrane gérée. Pour l’automatiser dans un produit, il faut également un consentement, des conditions de fournisseur adaptées, la conservation des résultats de recherche, des limites opérationnelles et un processus de publication validé.