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.