PageSourceSearch

https://www.lawforums.co.il/wp-content/plugins/tiktok-feeds-pro/assets/js/heic-converter.js?ver=1.5.0

js lawforums.co.il collected 2026-10-02 07:27:17 UTC 30,174 bytes, 782 lines download raw bytes

1/**
2 * SBTT HEIC to JPEG Converter
3 *
4 * Simple, efficient client-side HEIC image converter for TikTok feeds.
5 * Only converts images that server has attempted to process and failed.
6 *
7 * @package tiktok-feeds
8 * @since 1.2.0
9 */
10
11(function($) {
12    'use strict';
13
14    const SBTTHeicConverter = {
15        // Track processing to avoid duplicates
16        processing: new Set(),
17
18        // Batch queue
19        queue: [],
20        batchTimer: null,
21        maxBatchSize: 10, // Limit batch size to prevent server timeouts
22        maxRetries: 2, // Maximum retry attempts for failed uploads
23
24        // Track blob URLs for cleanup
25        blobUrls: new Set(),
26
27        // Track MutationObserver for cleanup
28        observers: [],
29
30        // Browser HEIC support detection cache
31        heicSupport: {
32            detected: false,
33            isSupported: null,
34
35            detect: function() {
36                if (this.detected) {
37                    return Promise.resolve(this.isSupported);
38                }
39
40                // Check sessionStorage first
41                const cached = sessionStorage.getItem('sbtt_heic_support');
42                if (cached !== null) {
43                    this.detected = true;
44                    this.isSupported = cached === 'true';
45                    return Promise.resolve(this.isSupported);
46                }
47
48                // Perform detection with test image
49                return new Promise((resolve) => {
50                    const img = new Image();
51                    const timeout = setTimeout(() => {
52                        this.detected = true;
53                        this.isSupported = false;
54                        sessionStorage.setItem('sbtt_heic_support', 'false');
55                        resolve(false);
56                    }, 1000);
57
58                    img.onload = () => {
59                        clearTimeout(timeout);
60                        this.detected = true;
61                        this.isSupported = img.naturalHeight > 0;
62                        sessionStorage.setItem('sbtt_heic_support', this.isSupported ? 'true' : 'false');
63                        resolve(this.isSupported);
64                    };
65
66                    img.onerror = () => {
67                        clearTimeout(timeout);
68                        this.detected = true;
69                        this.isSupported = false;
70                        sessionStorage.setItem('sbtt_heic_support', 'false');
71                        resolve(false);
72                    };
73
74                    // Use tiny HEIC test image data URI
75                    img.src = 'data:image/heic;base64,AAAAHGZ0eXBoZWljAAAAAG1pZjFoZWljbWlhZgAAABhtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAA';
76                });
77            }
78        },
79
80        // Maximum queue size to prevent memory exhaustion
81        maxQueueSize: 20,
82
83        /**
84         * Check if user can save images to server
85         * Requires: logged in user + optimize_images setting enabled
86         */
87        canSaveToServer: function() {
88            return sbttHeicData && sbttHeicData.isUserLoggedIn && sbttHeicData.optimizeImages;
89        },
90
91        /**
92         * LocalStorage Management for HEIC Conversions
93         * Stores converted JPEG data URLs for non-logged-in users
94         */
95        storage: {
96            prefix: 'sbtt_heic_',
97            version: '1',
98            maxSize: 4 * 1024 * 1024, // 4MB total (under 5MB quota)
99            maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
100
101            isAvailable: function() {
102                // Feature flag check
103                if (sbttHeicData && sbttHeicData.useLocalStorage === false) {
104                    return false;
105                }
106
107                try {
108                    const test = '__sbtt_test__';
109                    localStorage.setItem(test, test);
110                    localStorage.removeItem(test);
111                    return true;
112                } catch (e) {
113                    return false;
114                }
115            },
116
117            get: function(videoId) {
118                if (!this.isAvailable()) return null;
119
120                const key = this.prefix + videoId + '_v' + this.version;
121                const data = localStorage.getItem(key);
122
123                if (!data) return null;
124
125                try {
126                    const parsed = JSON.parse(data);
127
128                    // Check expiry
129                    if (Date.now() - parsed.timestamp > this.maxAge) {
130                        this.remove(videoId);
131                        return null;
132                    }
133
134                    // Update last accessed for LRU
135                    parsed.lastAccessed = Date.now();
136                    localStorage.setItem(key, JSON.stringify(parsed));
137
138                    return parsed;
139                } catch (e) {
140                    console.error('Failed to parse stored HEIC data:', e);
141                    this.remove(videoId);
142                    return null;
143                }
144            },
145
146            set: function(videoId, dataUrl) {
147                if (!this.isAvailable()) return false;
148
149                const key = this.prefix + videoId + '_v' + this.version;
150                const data = {
151                    dataUrl: dataUrl,
152                    timestamp: Date.now(),
153                    lastAccessed: Date.now(),
154                    size: dataUrl.length
155                };
156
157                try {
158                    // Check if we need to make space
159                    const estimatedSize = JSON.stringify(data).length;
160                    if (this.getCurrentSize() + estimatedSize > this.maxSize) {
161                        this.evictLRU(estimatedSize);
162                    }
163
164                    localStorage.setItem(key, JSON.stringify(data));
165                    return true;
166                } catch (e) {
167                    console.warn('Failed to store HEIC data:', e);
168
169                    // Try evicting and retry once
170                    this.evictLRU(JSON.stringify(data).length);
171                    try {
172                        localStorage.setItem(key, JSON.stringify(data));
173                        return true;
174                    } catch (e2) {
175                        console.error('Failed after eviction:', e2);
176                        return false;
177                    }
178                }
179            },
180
181            remove: function(videoId) {
182                if (!this.isAvailable()) return;
183                const key = this.prefix + videoId + '_v' + this.version;
184                localStorage.removeItem(key);
185            },
186
187            getCurrentSize: function() {
188                let total = 0;
189                for (let i = 0; i < localStorage.length; i++) {
190                    const key = localStorage.key(i);
191                    if (key.startsWith(this.prefix)) {
192                        total += localStorage.getItem(key).length;
193                    }
194                }
195                return total;
196            },
197
198            evictLRU: function(spaceNeeded) {
199                const items = [];
200
201                for (let i = 0; i < localStorage.length; i++) {
202                    const key = localStorage.key(i);
203                    if (key.startsWith(this.prefix)) {
204                        try {
205                            const data = JSON.parse(localStorage.getItem(key));
206                            items.push({
207                                key: key,
208                                lastAccessed: data.lastAccessed || data.timestamp,
209                                size: localStorage.getItem(key).length
210                            });
211                        } catch (e) {
212                            localStorage.removeItem(key);
213                        }
214                    }
215                }
216
217                // Sort by lastAccessed (oldest first)
218                items.sort((a, b) => a.lastAccessed - b.lastAccessed);
219
220                // Remove oldest until we have enough space
221                let freedSpace = 0;
222                for (const item of items) {
223                    if (freedSpace >= spaceNeeded) break;
224                    localStorage.removeItem(item.key);
225                    freedSpace += item.size;
226                }
227            }
228        },
229
230        /**
231         * Initialize converter
232         */
233        init: function() {
234            this.loadLibrary().then(() => {
235                this.scanImages();
236                this.watchForNewImages();
237            }).catch((error) => {
238                console.error('Failed to load HEIC converter library:', error);
239                // Mark all HEIC images as failed so they don't get stuck in loading state
240                document.querySelectorAll('img[data-heic-url]').forEach(img => {
241                    if (!img.dataset.heicProcessed) {
242                        img.dataset.heicProcessed = 'failed';
243                    }
244                });
245            });
246        },
247
248        /**
249         * Load heic2any library
250         */
251        loadLibrary: function() {
252            return new Promise((resolve, reject) => {
253                if (typeof heic2any !== 'undefined') {
254                    resolve();
255                    return;
256                }
257
258                const script = document.createElement('script');
259                script.src = sbttHeicData.heic2anyUrl;
260                script.onload = resolve;
261                script.onerror = reject;
262                document.head.appendChild(script);
263            });
264        },
265
266        /**
267         * Scan for images needing conversion
268         */
269        scanImages: function() {
270            const images = document.querySelectorAll(
271                '.sbtt-post-item img, .sbsw-tiktok-item .sbsw-item-media img'
272            );
273            images.forEach(img => this.maybeConvert(img));
274        },
275
276        /**
277         * Watch for dynamically loaded images (load more, infinite scroll)
278         */
279        watchForNewImages: function() {
280            const observer = new MutationObserver(() => this.scanImages());
281
282            // Observe feed containers (TikTok Feeds standalone + Social Wall)
283            const containers = document.querySelectorAll(
284                '[id^="sbtt-feed"], .sb-feed-posts, [id^="sb-wall"], .sb-wall-items-wrap'
285            );
286            containers.forEach(container => {
287                observer.observe(container, {
288                    childList: true,
289                    subtree: true,
290                    attributes: true,
291                    attributeFilter: ['src']
292                });
293            });
294
295            // Track observer for cleanup
296            this.observers.push(observer);
297        },
298
299        /**
300         * Cleanup resources (for page unload or plugin deactivation)
301         */
302        cleanup: function() {
303            // Disconnect all observers
304            this.observers.forEach(observer => observer.disconnect());
305            this.observers = [];
306
307            // Revoke all blob URLs
308            this.blobUrls.forEach(url => URL.revokeObjectURL(url));
309            this.blobUrls.clear();
310
311            // Clear timers
312            clearTimeout(this.batchTimer);
313
314            // Clear queue to release DOM references
315            this.queue = [];
316            this.processing.clear();
317        },
318
319        /**
320         * Check if image should be converted
321         */
322        maybeConvert: function(img) {
323            // Try TikTok Feeds standalone first, then Social Wall
324            const postItem = img.closest('.sbtt-post-item') || img.closest('.sbsw-tiktok-item');
325            if (!postItem) return;
326
327            const videoId = postItem.dataset.postId;
328            if (!videoId || this.processing.has(videoId)) return;
329
330            const src = img.src;
331
332            // Must be HEIC and not already local
333            if (!src.toLowerCase().includes('.heic') || src.includes(sbttHeicData.uploadUrl)) return;
334
335            const processingAttempted = postItem.dataset.processingAttempted === '1';
336            const heicProcessed = img.dataset.heicProcessed;
337
338            // Skip if already being processed or converted
339            if (heicProcessed === 'converting' || heicProcessed === 'converted') return;
340
341            // ⭐ NEW: Check localStorage first for non-logged-in users
342            if (!this.canSaveToServer() && this.storage.isAvailable()) {
343                const cached = this.storage.get(videoId);
344                if (cached) {
345                    // Use cached conversion immediately - no network request needed!
346                    img.src = cached.dataUrl;
347                    img.dataset.heicProcessed = 'cached';
348                    return; // Done! No conversion needed
349                }
350            }
351
352            // If temp blob exists and server processing complete, save it
353            if (heicProcessed === 'temp' && processingAttempted) {
354                if (src.startsWith('blob:')) {
355                    // Prevent race condition: mark as processing immediately
356                    this.processing.add(videoId);
357                    this.saveBlob(img, src, videoId);
358                }
359                return;
360            }
361
362            // If native HEIC loaded and server processing complete, convert and save (logged in users only)
363            if (heicProcessed === 'native' && processingAttempted) {
364                // Prevent race condition: mark as processing immediately
365                this.processing.add(videoId);
366                this.convert(img, img.dataset.originalHeicUrl || src, videoId, this.canSaveToServer());
367                return;
368            }
369
370            // Already handled
371            if (heicProcessed) return;
372
373            // Store original URL before any processing
374            img.dataset.originalHeicUrl = src;
375
376            // Use cached browser detection instead of per-image detection
377            this.heicSupport.detect().then(isSupported => {
378                if (isSupported) {
379                    img.dataset.heicProcessed = 'native';
380                    if (processingAttempted && !this.processing.has(videoId)) {
381                        this.processing.add(videoId);
382                        this.convert(img, src, videoId, this.canSaveToServer());
383                    }
384                } else {
385                    if (!this.processing.has(videoId)) {
386                        this.processing.add(videoId);
387                        this.convert(img, src, videoId, processingAttempted && this.canSaveToServer());
388                    }
389                }
390            });
391        },
392
393        /**
394         * Save blob to server
395         */
396        saveBlob: function(img, blobUrl, videoId) {
397            fetch(blobUrl)
398                .then(res => res.blob())
399                .then(blob => this.blobToBase64(blob))
400                .then(base64 => {
401                    // Re-check queue size after async operation to prevent race condition
402                    if (this.queue.length >= this.maxQueueSize) {
403                        console.warn('Queue full after blob conversion, skipping save for ' + videoId);
404                        this.processing.delete(videoId);
405                        return;
406                    }
407
408                    this.queue.push({
409                        videoId: videoId,
410                        imageData: base64,
411                        originalUrl: img.dataset.originalHeicUrl,
412                        imgId: img.id || null, // Store ID instead of reference
413                        blobUrl: blobUrl,
414                        retryCount: 0
415                    });
416                    this.scheduleBatch();
417                    img.dataset.heicProcessed = 'converting';
418                })
419                .catch(() => {
420                    this.processing.delete(videoId);
421                    img.dataset.heicProcessed = 'failed';
422                });
423        },
424
425        /**
426         * Validate URL has safe protocol
427         */
428        isValidHeicUrl: function(url) {
429            try {
430                const parsedUrl = new URL(url);
431                // Only allow https/http protocols (prevent javascript:, data:, file: schemes)
432                if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
433                    return false;
434                }
435                return true;
436            } catch (e) {
437                return false;
438            }
439        },
440
441        /**
442         * Track and manage blob URLs to prevent memory leaks
443         */
444        createBlobUrl: function(blob) {
445            const url = URL.createObjectURL(blob);
446            this.blobUrls.add(url);
447            return url;
448        },
449
450        /**
451         * Revoke blob URL and remove from tracking
452         */
453        revokeBlobUrl: function(url) {
454            if (url && this.blobUrls.has(url)) {
455                URL.revokeObjectURL(url);
456                this.blobUrls.delete(url);
457            }
458        },
459
460        /**
461         * Validate HEIC file by checking content type and file signature
462         * (Client-side check for better UX - server validates converted JPEG)
463         */
464        validateHeic: function(response, blob) {
465            // Check Content-Type header
466            const contentType = response.headers.get('content-type');
467            if (contentType && !contentType.includes('heic') && !contentType.includes('heif') && !contentType.includes('octet-stream')) {
468                return Promise.reject('Invalid content type: ' + contentType);
469            }
470
471            // Check file signature (magic bytes)
472            return blob.slice(0, 12).arrayBuffer().then(buffer => {
473                const bytes = new Uint8Array(buffer);
474
475                // HEIC files have 'ftyp' at bytes 4-7, followed by brand identifiers
476                // Valid brands: heic, heix, hevc, hevx, mif1
477                if (bytes[4] !== 0x66 || bytes[5] !== 0x74 || bytes[6] !== 0x79 || bytes[7] !== 0x70) {
478                    return Promise.reject('Invalid HEIC file signature');
479                }
480
481                // Check for valid HEIC brand
482                const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
483                const validBrands = ['heic', 'heix', 'hevc', 'hevx', 'mif1'];
484                if (!validBrands.includes(brand)) {
485                    return Promise.reject('Invalid HEIC brand: ' + brand);
486                }
487
488                return blob;
489            });
490        },
491
492        /**
493         * Convert HEIC to JPEG
494         */
495        convert: function(img, heicUrl, videoId, saveLocally) {
496            // Validate URL before fetching (prevent SSRF)
497            if (!heicUrl.startsWith('blob:') && !this.isValidHeicUrl(heicUrl)) {
498                console.error('Invalid or unsafe HEIC URL:', heicUrl);
499                this.processing.delete(videoId);
500                img.dataset.heicProcessed = 'failed';
501                return;
502            }
503
504            img.dataset.heicProcessed = 'converting';
505            this.showLoading(img);
506
507            fetch(heicUrl)
508                .then(res => {
509                    if (!res.ok) {
510                        return Promise.reject('HTTP error: ' + res.status);
511                    }
512                    return res.blob().then(blob => this.validateHeic(res, blob));
513                })
514                .then(blob => heic2any({ blob: blob, toType: 'image/jpeg', quality: 0.9 }))
515                .then(jpeg => {
516                    // Always display converted image immediately
517                    const url = this.createBlobUrl(jpeg);
518                    img.src = url;
519                    this.hideLoading(img);
520
521                    // ⭐ NEW: Save to localStorage for non-logged-in users
522                    if (!saveLocally && this.storage.isAvailable()) {
523                        return this.blobToBase64(jpeg).then(base64 => {
524                            const saved = this.storage.set(videoId, base64);
525                            if (saved) {
526                                img.dataset.heicProcessed = 'cached';
527                                console.log('Saved to localStorage for ' + videoId);
528                            } else {
529                                img.dataset.heicProcessed = 'temp';
530                            }
531                            this.processing.delete(videoId);
532                        });
533                    }
534
535                    // Save to server if logged in (existing logic)
536                    if (saveLocally) {
537                        return this.blobToBase64(jpeg).then(base64 => {
538                            // Re-check queue size after async operation to prevent race condition
539                            if (this.queue.length >= this.maxQueueSize) {
540                                console.warn('Queue full after conversion, skipping upload for ' + videoId);
541                                img.dataset.heicProcessed = 'temp';
542                                this.processing.delete(videoId);
543                                return;
544                            }
545
546                            this.queue.push({
547                                videoId: videoId,
548                                imageData: base64,
549                                originalUrl: heicUrl,
550                                imgId: img.id || null, // Store ID instead of reference
551                                blobUrl: url,
552                                retryCount: 0
553                            });
554                            this.scheduleBatch();
555                        });
556                    } else {
557                        // Temporary conversion, mark as temp
558                        img.dataset.heicProcessed = 'temp';
559                        this.processing.delete(videoId);
560                    }
561                })
562                .catch(error => {
563                    this.hideLoading(img);
564                    this.processing.delete(videoId);
565                    img.dataset.heicProcessed = 'failed';
566                    console.error('HEIC conversion failed for ' + videoId + ':', error);
567                });
568        },
569
570        /**
571         * Schedule batch upload (debounced 500ms)
572         */
573        scheduleBatch: function() {
574            clearTimeout(this.batchTimer);
575            // Process immediately if we've hit max batch size
576            if (this.queue.length >= this.maxBatchSize) {
577                this.uploadBatch();
578            } else {
579                this.batchTimer = setTimeout(() => this.uploadBatch(), 500);
580            }
581        },
582
583        /**
584         * Upload batch of converted images
585         */
586        uploadBatch: function() {
587            if (this.queue.length === 0) return;
588
589            // Limit batch size to prevent server timeouts
590            const batch = this.queue.splice(0, this.maxBatchSize);
591            const data = batch.map(item => ({
592                video_id: item.videoId,
593                image_data: item.imageData,
594                original_url: item.originalUrl
595            }));
596
597            fetch(sbttHeicData.ajaxUrl, {
598                method: 'POST',
599                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
600                body: new URLSearchParams({
601                    action: 'sbtt_save_converted_heic_batch',
602                    nonce: sbttHeicData.nonce,
603                    images: JSON.stringify(data)
604                })
605            })
606            .then(res => res.json())
607            .then(response => {
608                // Create Map for O(1) lookups instead of O(n*m)
609                const batchMap = new Map(batch.map(item => [item.videoId, item]));
610                const processedIds = new Set();
611
612                // Process server response results
613                if (response.success && response.data && response.data.results) {
614                    response.data.results.forEach(result => {
615                        const item = batchMap.get(result.video_id);
616                        if (!item) return;
617
618                        processedIds.add(result.video_id);
619
620                        // Get img element dynamically (may have been removed from DOM)
621                        const img = this.getImageElement(item.videoId, item.imgId);
622
623                        if (result.success) {
624                            // Success: revoke blob URL and use local URL
625                            this.revokeBlobUrl(item.blobUrl);
626                            if (img) {
627                                img.src = result.local_url;
628                                img.dataset.heicProcessed = 'converted';
629                                this.hideLoading(img);
630                            }
631                            this.processing.delete(item.videoId);
632                        } else {
633                            // Failed: retry if attempts remaining
634                            this.handleUploadFailure(item, result.message || 'Unknown error');
635                        }
636                    });
637                }
638
639                // Ensure all items are handled: process any not in server response
640                batchMap.forEach((item, videoId) => {
641                    if (!processedIds.has(videoId)) {
642                        this.handleUploadFailure(item, 'Server did not process request');
643                    }
644                });
645
646                // Process remaining items in queue if any
647                if (this.queue.length > 0) {
648                    this.scheduleBatch();
649                }
650            })
651            .catch(error => {
652                console.error('Batch upload failed:', error);
653                // Ensure all items are handled on network error
654                batch.forEach(item => {
655                    this.handleUploadFailure(item, 'Network error');
656                });
657            });
658        },
659
660        /**
661         * Get image element by videoId or imgId (handle removed DOM elements)
662         */
663        getImageElement: function(videoId, imgId) {
664            // Try by imgId first if available
665            if (imgId) {
666                const img = document.getElementById(imgId);
667                if (img) return img;
668            }
669
670            // TikTok Feeds standalone
671            var postItem = document.querySelector('.sbtt-post-item[data-post-id="' + videoId + '"]');
672            if (postItem) return postItem.querySelector('img');
673
674            // Social Wall
675            postItem = document.querySelector('.sbsw-tiktok-item[data-post-id="' + videoId + '"]');
676            return postItem ? postItem.querySelector('.sbsw-item-media img') : null;
677        },
678
679        /**
680         * Handle upload failure with retry logic
681         */
682        handleUploadFailure: function(item, errorMessage) {
683            item.retryCount = (item.retryCount || 0) + 1;
684
685            if (item.retryCount <= this.maxRetries) {
686                // Retry: add back to queue if not full
687                if (this.queue.length < this.maxQueueSize) {
688                    console.log('Retrying upload for ' + item.videoId + ' (attempt ' + item.retryCount + ')');
689                    this.queue.push(item);
690                    this.scheduleBatch();
691                } else {
692                    console.warn('Queue full, cannot retry ' + item.videoId);
693                    this.cleanupFailedItem(item);
694                }
695            } else {
696                // Max retries exceeded
697                console.error('Upload failed for ' + item.videoId + ' after ' + this.maxRetries + ' retries:', errorMessage);
698                this.cleanupFailedItem(item);
699            }
700        },
701
702        /**
703         * Cleanup after failed upload (revoke blob if not used, update state)
704         */
705        cleanupFailedItem: function(item) {
706            const img = this.getImageElement(item.videoId, item.imgId);
707            if (img) {
708                this.hideLoading(img);
709                // Keep blob URL if img still references it, otherwise revoke
710                if (img.src !== item.blobUrl) {
711                    this.revokeBlobUrl(item.blobUrl);
712                }
713                img.dataset.heicProcessed = 'temp'; // Keep as temp (blob URL may still work)
714            } else {
715                // Image removed from DOM, revoke blob
716                this.revokeBlobUrl(item.blobUrl);
717            }
718            this.processing.delete(item.videoId);
719        },
720
721        /**
722         * Convert blob to base64
723         */
724        blobToBase64: function(blob) {
725            return new Promise((resolve, reject) => {
726                const reader = new FileReader();
727                reader.onloadend = () => resolve(reader.result);
728                reader.onerror = reject;
729                reader.readAsDataURL(blob);
730            });
731        },
732
733        /**
734         * Show loading indicator (with wrapper caching)
735         */
736        showLoading: function(img) {
737            if (!img) return;
738
739            img.style.opacity = '0.5';
740            const wrapper = img.closest('.sb-post-item-image') || img.closest('.sbsw-item-media');
741            if (wrapper && !wrapper.querySelector('.sbtt-converting-badge')) {
742                const badge = document.createElement('div');
743                badge.className = 'sbtt-converting-badge';
744                badge.innerHTML = '<span class="sbtt-spinner"></span> Converting...';
745                wrapper.style.position = 'relative';
746                wrapper.appendChild(badge);
747            }
748        },
749
750        /**
751         * Hide loading indicator
752         */
753        hideLoading: function(img) {
754            if (!img) return;
755
756            img.style.opacity = '1';
757            const wrapper = img.closest('.sb-post-item-image') || img.closest('.sbsw-item-media');
758            if (wrapper) {
759                const badge = wrapper.querySelector('.sbtt-converting-badge');
760                if (badge) badge.remove();
761            }
762        }
763    };
764
765    // Initialize on DOM ready
766    $(document).ready(function() {
767        if (typeof sbttHeicData !== 'undefined') {
768            SBTTHeicConverter.init();
769        }
770    });
771
772    // Handle AJAX load more events
773    $(document).on('sbtt_feed_loaded', function() {
774        SBTTHeicConverter.scanImages();
775    });
776
777    // Cleanup on page unload to prevent memory leaks
778    $(window).on('beforeunload', function() {
779        SBTTHeicConverter.cleanup();
780    });
781
782})(jQuery);

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.