Extract Exif Metadata from Image with AI Component

,

Hello Gliders,

Here is an exif extractor I was able to make with AI Component.

Set the URL of the image, then it will save in the column you select here as JSON:


Use this prompt:

Variables :
- imageUrl
- exifData

<html style="height: auto !important; --color-accent: 215deg 99% 28%;" class="color-scheme-auto"><head>
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap" as="style">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap">
<meta name="color-scheme" content="light dark">
<style>
html, body {
font-family: 'Inter', sans-serif;
background-color: transparent;
}
</style>
<script type="module">
import { init } from "https://components.glide.info/shell/v1.js";
document.addEventListener("DOMContentLoaded", () => {
init({"fieldsDataObject":{"imageUrl":null,"exifData":null},"schema":[{"name":"imageUrl","type":"string","id":"imageUrl"},{"name":"exifData","type":"string","id":"exifData"}],"actions":[],"debug":false})
});
</script>
<style>/*! tailwindcss v3.4.10 | MIT License | [https://tailwindcss.com](https://tailwindcss.com/)*/*,:after,:before{border-color:hsl(var(--color-gray-200)/1);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:hsl(var(--color-gray-400)/1);opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.headline-lg,.headline-md,.headline-sm,.headline-xl,.headline-xs{font-weight:600}.headline-sm{font-size:20px;letter-spacing:-.025em;line-height:1.375}@media (min-width:768px){.headline-sm{font-size:1.25rem;line-height:1.75rem}}.body-base{font-size:1rem;line-height:1.5rem;line-height:1.5}@media (min-width:768px){.body-base{font-size:.875rem;line-height:1.25rem}}.mb-4{margin-bottom:1rem}.h-auto{height:auto}.w-full{width:100%}.cursor-pointer{cursor:pointer}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.overflow-x-auto{overflow-x:auto}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.bg-background{--tw-bg-opacity:1;background-color:hsl(var(--color-background-DEFAULT)/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:hsl(var(--color-gray-100)/var(--tw-bg-opacity))}.p-3{padding:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-gray-600{--tw-text-opacity:1;color:hsl(var(--color-gray-600)/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:hsl(var(--color-gray-900)/var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.\@container{container-type:inline-size}@layer colors{:root.color-scheme-auto{--color-gray-0:0deg 0% 100%;--color-gray-50:0deg 0% 98%;--color-gray-100:0deg 0% 96%;--color-gray-200:0deg 0% 94%;--color-gray-300:0deg 0% 88%;--color-gray-400:0deg 0% 68%;--color-gray-500:0deg 0% 56%;--color-gray-600:0deg 0% 44%;--color-gray-700:0deg 0% 31%;--color-gray-800:0deg 0% 20%;--color-gray-900:0deg 0% 5%;--color-gray-950:0deg 0% 0%;--color-background-DEFAULT:0deg 0% 100%;@media (prefers-color-scheme:dark){--color-gray-0:0deg 0% 4%;--color-gray-50:0deg 0% 9%;--color-gray-100:0deg 0% 12%;--color-gray-200:0deg 0% 15%;--color-gray-300:0deg 0% 20%;--color-gray-400:0deg 0% 31%;--color-gray-500:0deg 0% 44%;--color-gray-600:0deg 0% 57%;--color-gray-700:0deg 0% 72%;--color-gray-800:0deg 0% 90%;--color-gray-900:0deg 0% 100%;--color-gray-950:0deg 0% 100%;--color-background-DEFAULT:0deg 0% 9%}}:root.color-scheme-light{color-scheme:light;--color-gray-0:0deg 0% 100%;--color-gray-50:0deg 0% 98%;--color-gray-100:0deg 0% 96%;--color-gray-200:0deg 0% 94%;--color-gray-300:0deg 0% 88%;--color-gray-400:0deg 0% 68%;--color-gray-500:0deg 0% 56%;--color-gray-600:0deg 0% 44%;--color-gray-700:0deg 0% 31%;--color-gray-800:0deg 0% 20%;--color-gray-900:0deg 0% 5%;--color-gray-950:0deg 0% 0%;--color-background-DEFAULT:0deg 0% 100%}:root.color-scheme-dark{color-scheme:dark;--color-gray-0:0deg 0% 4%;--color-gray-50:0deg 0% 9%;--color-gray-100:0deg 0% 12%;--color-gray-200:0deg 0% 15%;--color-gray-300:0deg 0% 20%;--color-gray-400:0deg 0% 31%;--color-gray-500:0deg 0% 44%;--color-gray-600:0deg 0% 57%;--color-gray-700:0deg 0% 72%;--color-gray-800:0deg 0% 90%;--color-gray-900:0deg 0% 100%;--color-gray-950:0deg 0% 100%;--color-background-DEFAULT:0deg 0% 9%}}@media (min-width:640px){.sm\:px-5{padding-left:1.25rem;padding-right:1.25rem}}@media (min-width:768px){.md\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.lg\:px-7{padding-left:1.75rem;padding-right:1.75rem}}@media (min-width:1280px){.xl\:px-8{padding-left:2rem;padding-right:2rem}}</style>
</head>
<body x-data="state" class="" :class="{ 'cursor-pointer': hasBodyAction }" style="height: auto !important;" data-iframe-overflow="">
<div x-show="themeLoaded" class="@container w-full body-base antialiased">
<div :class="{ 'px-4 py-1 sm:px-5 md:px-6 lg:px-7 xl:px-8': mode === 'standalone' }" class="px-4 py-1 sm:px-5 md:px-6 lg:px-7 xl:px-8">
<div class="bg-background rounded-lg" x-data="exifData">
<div class="mb-4">
<img :src="imageUrl" alt="Uploaded image" class="w-full h-auto rounded-lg" src="https://raw.githubusercontent.com/ianare/exif-samples/refs/heads/master/jpg/Canon_PowerShot_S40.jpg">
</div>
<div class="space-y-2">
<h3 class="headline-sm text-gray-900">EXIF Data</h3>
<pre x-show="exifData" class="bg-gray-100 p-3 rounded-md text-sm text-gray-600 overflow-x-auto" x-text="exifData"></pre>
<p x-show="!exifData" class="text-gray-600" data-iframe-overflow="" style="display: none;">No EXIF data available for this image.</p>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('exifData', () => ({
init() {
this.loadExifData();
this.$watch('imageUrl', () => this.loadExifData());

//this.$watch(this.exifData, () => {});
},
async loadExifData() {
try {
const response = await fetch(this.imageUrl);
const blob = await response.blob();

const img = document.createElement('img');
img.src = URL.createObjectURL(blob);

img.onload = () => {
EXIF.getData(img, () => {
const exifDataT = EXIF.getAllTags(img);
if (Object.keys(exifDataT).length > 0) {
this.exifData = JSON.stringify(exifDataT, null, 2);
} else {
this.exifData = '';
}
});
};
} catch (error) {
console.error('Error reading EXIF data:', error);
this.exifData = '';
}
}
}));
});
</script>
</div>
</div>

<script src="//[cdn.jsdelivr.net/npm/@iframe-resizer/child@5.2.1](http://cdn.jsdelivr.net/npm/@iframe-resizer/child@5.2.1)"></script>
<div style="clear: both; display: block; height: 0px;"></div></body></html>

Here is the demo app:

5 Likes

Hi Maxime, Great!, can you continue and help us to retrieve EXIF Json from one Image column to another Json column to be able to use this data in Glide Apps. Nice day

Isn’t that exactly what it does?

:point_down:

2 Likes

This is exactly what this thing does.

Nice job, I haven’t seen it before!

Can we have/extract the image’s gps coordinates as well?

Saludos @MaximeBaker

If you image contain GPS information in the exif, you will automatically see it.

Can confirm this works. Trying with an image here.

I don’t understand how to implement this. Can someone tell me the steps? do i create a url field up[load file to that then run the AI script

Add a Custom AI component and paste the code into the AI prompt.

2 Likes

Thanks, I could do that, but I wanted to have it as an action on upload if possible.

You can use a regular javascript column to get the same data.

Here is something I put together. You can pass the image column in the P1 parameter. I’m not sure what you are looking for as a result, but I set it up to return a JSON string.

async function getExifFromUrl(imageUrl) {
    try {
        const response = await fetch(imageUrl, { mode: 'cors' });
        const buffer = await response.arrayBuffer();
        const view = new DataView(buffer);

        // Must be a JPEG
        if (view.getUint16(0, false) !== 0xFFD8) {
            return "";
        }

        let offset = 2;
        const length = view.byteLength;

        while (offset < length) {
            if (view.getUint16(offset, false) === 0xFFE1) { // APP1 marker
                const app1Length = view.getUint16(offset + 2, false);
                const exifHeader = getString(view, offset + 4, 6);
                if (exifHeader === "Exif\0\0") {
                    const tags = parseExifData(view, offset + 10, app1Length - 8);
                    return JSON.stringify(tags);
                }
            }
            offset += 2 + view.getUint16(offset + 2, false);
        }

        return "";
    } catch {
        return "";
    }
}

function parseExifData(view, start, length) {
    const tiffHeader = start;
    const littleEndian = view.getUint16(tiffHeader, false) === 0x4949;
    const firstIFDOffset = view.getUint32(tiffHeader + 4, littleEndian);

    let tags = {};
    readIFD(view, tiffHeader, tiffHeader + firstIFDOffset, littleEndian, tags);

    // GPS IFD
    if (tags["GPSInfoIFDPointer"]) {
        tags["GPS"] = {};
        readIFD(view, tiffHeader, tiffHeader + tags["GPSInfoIFDPointer"], littleEndian, tags["GPS"]);
        delete tags["GPSInfoIFDPointer"];
    }

    return tags;
}

function readIFD(view, tiffHeader, dirStart, littleEndian, tags) {
    const entries = view.getUint16(dirStart, littleEndian);
    for (let i = 0; i < entries; i++) {
        const entryOffset = dirStart + 2 + i * 12;
        const tagId = view.getUint16(entryOffset, littleEndian);
        const type = view.getUint16(entryOffset + 2, littleEndian);
        const numValues = view.getUint32(entryOffset + 4, littleEndian);
        const valueOffset = entryOffset + 8;

        let value;
        if (type === 2) { // ASCII string
            const offset = (numValues > 4) ? view.getUint32(valueOffset, littleEndian) + tiffHeader : valueOffset;
            value = getString(view, offset, numValues).replace(/\0/g, '');
        } else if (type === 3) { // SHORT
            value = (numValues === 1) ? view.getUint16(valueOffset, littleEndian) : getShortArray(view, tiffHeader, valueOffset, numValues, littleEndian);
        } else if (type === 4) { // LONG
            value = (numValues === 1) ? view.getUint32(valueOffset, littleEndian) : getLongArray(view, tiffHeader, valueOffset, numValues, littleEndian);
        } else if (type === 5) { // RATIONAL
            value = (numValues === 1) ? getRational(view, tiffHeader + view.getUint32(valueOffset, littleEndian), littleEndian) : getRationalArray(view, tiffHeader, valueOffset, numValues, littleEndian);
        } else {
            value = null;
        }

        const tagName = exifTagNames[tagId] || `Tag_${tagId}`;
        tags[tagName] = value;
    }
}

function getString(view, start, length) {
    let out = "";
    for (let i = start; i < start + length; i++) {
        out += String.fromCharCode(view.getUint8(i));
    }
    return out;
}

function getShortArray(view, tiffHeader, valueOffset, count, littleEndian) {
    let arr = [];
    let offset = view.getUint32(valueOffset, littleEndian) + tiffHeader;
    for (let i = 0; i < count; i++) {
        arr.push(view.getUint16(offset + i * 2, littleEndian));
    }
    return arr;
}

function getLongArray(view, tiffHeader, valueOffset, count, littleEndian) {
    let arr = [];
    let offset = view.getUint32(valueOffset, littleEndian) + tiffHeader;
    for (let i = 0; i < count; i++) {
        arr.push(view.getUint32(offset + i * 4, littleEndian));
    }
    return arr;
}

function getRational(view, offset, littleEndian) {
    const num = view.getUint32(offset, littleEndian);
    const den = view.getUint32(offset + 4, littleEndian);
    return den === 0 ? null : num / den;
}

function getRationalArray(view, tiffHeader, valueOffset, count, littleEndian) {
    let arr = [];
    let offset = view.getUint32(valueOffset, littleEndian) + tiffHeader;
    for (let i = 0; i < count; i++) {
        arr.push(getRational(view, offset + i * 8, littleEndian));
    }
    return arr;
}

// Lookup table for standard EXIF/TIFF/GPS tags
const exifTagNames = {
    // TIFF baseline
    0x010F: "Make",
    0x0110: "Model",
    0x0112: "Orientation",
    0x0131: "Software",
    0x0132: "DateTime",
    0x013B: "Artist",
    0x0213: "YCbCrPositioning",
    0x013E: "WhitePoint",
    0x013F: "PrimaryChromaticities",
    0x0211: "YCbCrCoefficients",
    0x0214: "ReferenceBlackWhite",
    0x8769: "ExifIFDPointer",
    0x8825: "GPSInfoIFDPointer",
    0x011A: "XResolution",
    0x011B: "YResolution",
    0x0128: "ResolutionUnit",

    // EXIF SubIFD
    0x9000: "ExifVersion",
    0x9003: "DateTimeOriginal",
    0x9004: "DateTimeDigitized",
    0x9201: "ShutterSpeedValue",
    0x9202: "ApertureValue",
    0x9204: "ExposureBiasValue",
    0x9206: "SubjectDistance",
    0x9207: "MeteringMode",
    0x9209: "Flash",
    0x920A: "FocalLength",
    0xA002: "PixelXDimension",
    0xA003: "PixelYDimension",

    // GPS
    0x0000: "GPSVersionID",
    0x0001: "GPSLatitudeRef",
    0x0002: "GPSLatitude",
    0x0003: "GPSLongitudeRef",
    0x0004: "GPSLongitude",
    0x0005: "GPSAltitudeRef",
    0x0006: "GPSAltitude",
    0x0007: "GPSTimeStamp",
    0x000D: "GPSDateStamp"
};


return await getExifFromUrl(p1);

2 Likes

Thanks, really helpful!

1 Like