PageSourceSearch

https://associationforpublicart.org/wp-content/themes/vast/assets/js/smart-focal.js?ver=1.0.1.1790910075

js associationforpublicart.org collected 2026-10-02 03:01:24 UTC 11,105 bytes, 265 lines download raw bytes

1/**
2 * Smart Focal Point
3 *
4 * For every <img data-smart-focal> on the page:
5 *   - If the attribute value is "X,Y" (explicit percentages), apply object-position
6 *     immediately. This is the editor-override path.
7 *   - If the value is "auto" (or empty), load smartcrop.js once, analyze the image,
8 *     and set object-position to the centroid of the most-salient crop region.
9 *
10 * Paired with CSS `object-fit: cover` on the same image or its wrapper,
11 * this keeps the important region visible at any crop aspect.
12 */
13
14(function () {
15    'use strict';
16
17    const SMARTCROP_SRC = 'https://unpkg.com/[email protected]/smartcrop.js';
18
19    function loadSmartCrop() {
20        return new Promise(function (resolve, reject) {
21            if (window.SmartCrop) { resolve(window.SmartCrop); return; }
22            let s = document.querySelector('script[data-smartcrop]');
23            if (s) {
24                s.addEventListener('load', function () { resolve(window.SmartCrop); });
25                s.addEventListener('error', reject);
26                return;
27            }
28            s = document.createElement('script');
29            s.src = SMARTCROP_SRC;
30            s.setAttribute('data-smartcrop', '');
31            s.addEventListener('load', function () { resolve(window.SmartCrop); });
32            s.addEventListener('error', reject);
33            document.head.appendChild(s);
34        });
35    }
36
37    function applyFocal(img, xPct, yPct) {
38        const clamp = function (v) { return Math.max(0, Math.min(100, v)); };
39        img.style.objectPosition = clamp(xPct) + '% ' + clamp(yPct) + '%';
40        img.setAttribute('data-smart-focal-applied', '1');
41    }
42
43    function whenLoaded(img) {
44        return new Promise(function (resolve) {
45            if (img.complete && img.naturalWidth > 0) { resolve(); return; }
46            img.addEventListener('load', function () { resolve(); }, { once: true });
47            img.addEventListener('error', function () { resolve(); }, { once: true });
48        });
49    }
50
51    function targetAspect(img) {
52        // Use the container/displayed aspect so SmartCrop searches for the most
53        // interesting subregion that matches what object-fit: cover will actually show.
54        const rW = img.clientWidth  || img.offsetWidth;
55        const rH = img.clientHeight || img.offsetHeight;
56        if (rW > 0 && rH > 0) return { w: rW, h: rH };
57        // Container not laid out — fall back to the parent's rect.
58        const p = img.parentElement;
59        if (p) {
60            const r = p.getBoundingClientRect();
61            if (r.width > 0 && r.height > 0) return { w: r.width, h: r.height };
62        }
63        // Last resort — bias toward a landscape 16:9 search so SmartCrop returns
64        // a meaningful subregion instead of the whole square image.
65        return { w: 160, h: 90 };
66    }
67
68    /**
69     * Face detection — uses the browser's built-in FaceDetector API where
70     * available (Chrome behind a flag), otherwise lazy-loads @vladmandic/face-api
71     * (a face-api.js fork) with the tinyFaceDetector model. Both return faces
72     * as bounding boxes; we take the area-weighted centroid so a prominent face
73     * dominates over a tiny face in the background.
74     */
75    const FACE_API_URL   = 'https://cdn.jsdelivr.net/npm/@vladmandic/[email protected]/dist/face-api.min.js';
76    const FACE_MODEL_URI = 'https://cdn.jsdelivr.net/npm/@vladmandic/[email protected]/model';
77    let _faceApiPromise = null;
78
79    function loadFaceApi() {
80        if (_faceApiPromise) return _faceApiPromise;
81        _faceApiPromise = new Promise(function (resolve, reject) {
82            if (window.faceapi && window.faceapi.nets && window.faceapi.nets.tinyFaceDetector) {
83                if (window.faceapi.nets.tinyFaceDetector.params) return resolve(window.faceapi);
84            }
85            const finishLoad = function () {
86                window.faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URI)
87                    .then(function () { resolve(window.faceapi); })
88                    .catch(reject);
89            };
90            if (window.faceapi) { finishLoad(); return; }
91            const s = document.createElement('script');
92            s.src = FACE_API_URL;
93            s.onload = finishLoad;
94            s.onerror = reject;
95            document.head.appendChild(s);
96        });
97        return _faceApiPromise;
98    }
99
100    /** Returns area-weighted centroid in image-space %, or null. */
101    function centroidFromBoxes(boxes, naturalW, naturalH) {
102        let totalArea = 0, cx = 0, cy = 0;
103        for (const b of boxes) {
104            if (!b || !b.width || !b.height) continue;
105            const a = b.width * b.height;
106            totalArea += a;
107            cx += (b.x + b.width / 2) * a;
108            cy += (b.y + b.height / 2) * a;
109        }
110        if (!totalArea) return null;
111        return {
112            xPct: (cx / totalArea / naturalW) * 100,
113            yPct: (cy / totalArea / naturalH) * 100,
114        };
115    }
116
117    async function runFaceDetector(img) {
118        if (!img.naturalWidth || !img.naturalHeight) return null;
119
120        // Path 1: browser-native FaceDetector (Chrome flag, Edge).
121        if (typeof window.FaceDetector !== 'undefined') {
122            try {
123                const fd = new window.FaceDetector({ fastMode: true, maxDetectedFaces: 5 });
124                const faces = await fd.detect(img);
125                if (faces && faces.length) {
126                    const centroid = centroidFromBoxes(faces.map(f => f.boundingBox), img.naturalWidth, img.naturalHeight);
127                    if (centroid) return centroid;
128                }
129            } catch (_e) { /* fall through */ }
130        }
131
132        // Path 2: face-api.js with tinyFaceDetector — works in all modern browsers.
133        try {
134            const faceapi = await loadFaceApi();
135            const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.45 });
136            const detections = await faceapi.detectAllFaces(img, opts);
137            if (!detections || !detections.length) return null;
138            const boxes = detections.map(d => ({
139                x: d.box.x, y: d.box.y, width: d.box.width, height: d.box.height,
140            }));
141            return centroidFromBoxes(boxes, img.naturalWidth, img.naturalHeight);
142        } catch (e) {
143            console.warn('[smart-focal] face-api skipped', img.src, e && e.message);
144            return null;
145        }
146    }
147
148    async function runSmartCrop(img) {
149        if (!img.naturalWidth || !img.naturalHeight) return;
150        try {
151            const SmartCrop = await loadSmartCrop();
152            const t = targetAspect(img);
153            // Subject-at-top boost: weight the upper third of the image more so the
154            // crop centers on heads / busts / building peaks for typical compositions.
155            // SmartCrop's saliency analysis still finds the topCrop; we just nudge it.
156            // Weight is moderate (0.6) — strong enough to lift a faces-at-top shot,
157            // weak enough that a clearly-bottom-focused composition still wins.
158            const boosts = [{
159                x: 0,
160                y: 0,
161                width: img.naturalWidth,
162                height: Math.floor(img.naturalHeight * 0.45),
163                weight: 0.6,
164            }];
165            const result = await SmartCrop.crop(img, { width: t.w, height: t.h, boost: boosts });
166            if (!result || !result.topCrop) return;
167            const c = result.topCrop;
168            const coversAll = (
169                c.x === 0 && c.y === 0 &&
170                c.width === img.naturalWidth && c.height === img.naturalHeight
171            );
172            if (coversAll) return;
173            let xPct = ((c.x + c.width / 2) / img.naturalWidth) * 100;
174            let yPct = ((c.y + c.height / 2) / img.naturalHeight) * 100;
175            // Safety clamp: for landscape images, if the chosen Y is in the extreme
176            // bottom (rare for "subject of interest"), pull it up to the upper third.
177            // This catches cases like bronze sculptures with pedestal-heavy saliency.
178            const aspect = img.naturalWidth / img.naturalHeight;
179            if (aspect >= 1.0 && yPct > 70) {
180                yPct = 35;
181            }
182            applyFocal(img, xPct, yPct);
183        } catch (e) {
184            // Silent — CORS / decode failures just fall back to default center crop.
185            console.warn('[smart-focal] skipped', img.src, e && e.message);
186        }
187    }
188
189    /** Auto-detect: faces first (where supported), then smartcrop saliency fallback. */
190    async function runAutoFocal(img) {
191        const face = await runFaceDetector(img);
192        if (face) {
193            applyFocal(img, face.xPct, face.yPct);
194            return;
195        }
196        await runSmartCrop(img);
197    }
198
199    function parseExplicit(value) {
200        if (!value || value === 'auto' || value === '1') return null;
201        const m = value.match(/^(-?\d+(?:\.\d+)?)[,\s]+(-?\d+(?:\.\d+)?)$/);
202        if (!m) return null;
203        return { x: parseFloat(m[1]), y: parseFloat(m[2]) };
204    }
205
206    function process(img) {
207        if (img.getAttribute('data-smart-focal-applied') === '1') return;
208        const explicit = parseExplicit(img.dataset.smartFocal);
209        if (explicit) {
210            applyFocal(img, explicit.x, explicit.y);
211            return;
212        }
213        whenLoaded(img).then(function () { runAutoFocal(img); });
214    }
215
216    let intersectionObserver = null;
217
218    function watch(img) {
219        if (!('IntersectionObserver' in window)) { process(img); return; }
220        if (!intersectionObserver) {
221            intersectionObserver = new IntersectionObserver(function (entries) {
222                entries.forEach(function (entry) {
223                    if (entry.isIntersecting) {
224                        process(entry.target);
225                        intersectionObserver.unobserve(entry.target);
226                    }
227                });
228            }, { rootMargin: '200px' });
229        }
230        intersectionObserver.observe(img);
231    }
232
233    function scanRoot(root) {
234        const imgs = root.querySelectorAll('img[data-smart-focal]');
235        Array.prototype.forEach.call(imgs, watch);
236    }
237
238    function observeAdditions() {
239        if (!('MutationObserver' in window)) return;
240        const mo = new MutationObserver(function (records) {
241            records.forEach(function (r) {
242                r.addedNodes.forEach(function (node) {
243                    if (node.nodeType !== 1) return;
244                    if (node.tagName === 'IMG' && node.hasAttribute('data-smart-focal')) {
245                        watch(node);
246                    } else if (node.querySelectorAll) {
247                        scanRoot(node);
248                    }
249                });
250            });
251        });
252        mo.observe(document.body, { childList: true, subtree: true });
253    }
254
255    function init() {
256        scanRoot(document);
257        observeAdditions();
258    }
259
260    if (document.readyState === 'loading') {
261        document.addEventListener('DOMContentLoaded', init);
262    } else {
263        init();
264    }
265})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.