Skip to content

Commit 4466cb1

Browse files
committed
feat: add LCP diagnostic scripts (Sub-Parts, Trail, Image-Entropy, Video-Candidate)
1 parent d2d5660 commit 4466cb1

4 files changed

Lines changed: 849 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// LCP Image Entropy Check
2+
// https://webperf-snippets.nucliweb.net
3+
4+
(() => {
5+
const formatBytes = (bytes) => {
6+
if (!bytes) return "-";
7+
const k = 1024;
8+
const sizes = ["B", "KB", "MB"];
9+
const i = Math.floor(Math.log(bytes) / Math.log(k));
10+
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
11+
};
12+
13+
const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
14+
15+
// Get current LCP element
16+
let lcpElement = null;
17+
let lcpUrl = null;
18+
19+
const lcpObserver = new PerformanceObserver((list) => {
20+
const entries = list.getEntries();
21+
const lastEntry = entries[entries.length - 1];
22+
if (lastEntry) {
23+
lcpElement = lastEntry.element;
24+
lcpUrl = lastEntry.url;
25+
}
26+
});
27+
28+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
29+
30+
// Wait a tick to ensure LCP is captured (for human console output)
31+
setTimeout(() => {
32+
lcpObserver.disconnect();
33+
34+
// Get all images
35+
const images = [...document.images]
36+
.filter((img) => {
37+
const src = img.currentSrc || img.src;
38+
return src && !src.startsWith("data:image");
39+
})
40+
.map((img) => {
41+
const src = img.currentSrc || img.src;
42+
const resource = performance.getEntriesByName(src)[0];
43+
const fileSize = resource?.encodedBodySize || 0;
44+
const pixels = img.naturalWidth * img.naturalHeight;
45+
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
46+
47+
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
48+
const isLCP = lcpElement === img || lcpUrl === src;
49+
50+
return {
51+
element: img,
52+
src,
53+
shortSrc: src.split("/").pop()?.split("?")[0] || src,
54+
width: img.naturalWidth,
55+
height: img.naturalHeight,
56+
fileSize,
57+
bpp,
58+
isLowEntropy,
59+
isLCP,
60+
lcpEligible: !isLowEntropy && bpp > 0,
61+
};
62+
})
63+
.filter((img) => img.bpp > 0); // Only images with measurable BPP
64+
65+
console.group("%c🖼️ Image Entropy Analysis", "font-weight: bold; font-size: 14px;");
66+
67+
if (images.length === 0) {
68+
console.log(" No images with measurable entropy found.");
69+
console.log(" (Data URLs and cross-origin images without CORS are excluded)");
70+
console.groupEnd();
71+
return;
72+
}
73+
74+
// Summary
75+
const lowEntropy = images.filter((img) => img.isLowEntropy);
76+
const normalEntropy = images.filter((img) => !img.isLowEntropy);
77+
const lcpImage = images.find((img) => img.isLCP);
78+
79+
console.log("");
80+
console.log("%cSummary:", "font-weight: bold;");
81+
console.log(` Total images analyzed: ${images.length}`);
82+
console.log(` 🟢 Normal entropy (LCP eligible): ${normalEntropy.length}`);
83+
console.log(` 🔴 Low entropy (LCP ineligible): ${lowEntropy.length}`);
84+
85+
if (lcpImage) {
86+
const icon = lcpImage.isLowEntropy ? "⚠️" : "✅";
87+
console.log("");
88+
console.log(`%c${icon} Current LCP image:`, "font-weight: bold;");
89+
console.log(` ${lcpImage.shortSrc}`);
90+
console.log(` BPP: ${lcpImage.bpp.toFixed(4)} ${lcpImage.isLowEntropy ? "(LOW - may be skipped!)" : "(OK)"}`);
91+
}
92+
93+
// Table
94+
console.log("");
95+
console.log("%cAll Images:", "font-weight: bold;");
96+
97+
const tableData = images
98+
.sort((a, b) => b.bpp - a.bpp)
99+
.map((img) => ({
100+
Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
101+
Dimensions: `${img.width}×${img.height}`,
102+
Size: formatBytes(img.fileSize),
103+
BPP: img.bpp.toFixed(4),
104+
Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
105+
"LCP Eligible": img.lcpEligible ? "✅" : "❌",
106+
"Is LCP": img.isLCP ? "👈" : "",
107+
}));
108+
109+
console.table(tableData);
110+
111+
// Warnings
112+
if (lowEntropy.length > 0) {
113+
console.log("");
114+
console.log("%c⚠️ Low Entropy Images:", "font-weight: bold; color: #f59e0b;");
115+
console.log(" These images will NOT be considered for LCP in Chrome 112+:");
116+
lowEntropy.forEach((img) => {
117+
console.log(` • ${img.shortSrc} (BPP: ${img.bpp.toFixed(4)})`, img.element);
118+
});
119+
}
120+
121+
if (lcpImage && lcpImage.isLowEntropy) {
122+
console.log("");
123+
console.log("%c🚨 Warning:", "font-weight: bold; color: #ef4444;");
124+
console.log(" Your LCP image has low entropy and may be skipped by Chrome!");
125+
console.log(" Chrome will use the next largest element instead.");
126+
console.log("");
127+
console.log("%c💡 Solutions:", "font-weight: bold; color: #3b82f6;");
128+
console.log(" • Replace placeholder with actual content image");
129+
console.log(" • Use a text element as LCP instead");
130+
console.log(" • Ensure hero image loads with sufficient detail");
131+
}
132+
133+
// Elements for inspection
134+
console.log("");
135+
console.log("%c🔎 Inspect elements:", "font-weight: bold;");
136+
images.forEach((img, i) => {
137+
const icon = img.isLowEntropy ? "🔴" : "🟢";
138+
const lcpMark = img.isLCP ? " 👈 LCP" : "";
139+
console.log(` ${i + 1}. ${icon} ${img.shortSrc}${lcpMark}`, img.element);
140+
});
141+
142+
console.groupEnd();
143+
}, 100);
144+
145+
// Synchronous return for agent (buffered entries + DOM)
146+
const lcpEntriesSync = performance.getEntriesByType("largest-contentful-paint");
147+
const lcpEntrySync = lcpEntriesSync.at(-1);
148+
const lcpElementSync = lcpEntrySync?.element ?? null;
149+
const lcpUrlSync = lcpEntrySync?.url ?? null;
150+
const imagesSync = [...document.images]
151+
.filter((img) => { const src = img.currentSrc || img.src; return src && !src.startsWith("data:image"); })
152+
.map((img) => {
153+
const src = img.currentSrc || img.src;
154+
const resource = performance.getEntriesByName(src)[0];
155+
const fileSize = resource?.encodedBodySize || 0;
156+
const pixels = img.naturalWidth * img.naturalHeight;
157+
const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
158+
const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
159+
const isLCP = lcpElementSync === img || lcpUrlSync === src;
160+
return {
161+
url: src.split("/").pop()?.split("?")[0] || src,
162+
width: img.naturalWidth,
163+
height: img.naturalHeight,
164+
fileSizeBytes: fileSize,
165+
bpp: Math.round(bpp * 10000) / 10000,
166+
isLowEntropy,
167+
lcpEligible: !isLowEntropy && bpp > 0,
168+
isLCP,
169+
};
170+
})
171+
.filter((img) => img.bpp > 0);
172+
const lowEntropyCount = imagesSync.filter((img) => img.isLowEntropy).length;
173+
const lcpImageSync = imagesSync.find((img) => img.isLCP);
174+
const issuesSync = [];
175+
if (lowEntropyCount > 0) {
176+
issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
177+
}
178+
if (lcpImageSync?.isLowEntropy) {
179+
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
180+
}
181+
return {
182+
script: "LCP-Image-Entropy",
183+
status: "ok",
184+
count: imagesSync.length,
185+
details: {
186+
totalImages: imagesSync.length,
187+
lowEntropyCount,
188+
lcpImageEligible: lcpImageSync ? !lcpImageSync.isLowEntropy : null,
189+
lcpImage: lcpImageSync ? { url: lcpImageSync.url, bpp: lcpImageSync.bpp, isLowEntropy: lcpImageSync.isLowEntropy } : null,
190+
},
191+
items: imagesSync,
192+
issues: issuesSync,
193+
};
194+
})();

0 commit comments

Comments
 (0)