A 2.3 MB thumbnail is inside YouTube's limit when you upload it from a computer and over it when you upload from the mobile app. YouTube allows 50 MB from a computer and 2 MB from the app, so the same file passes in one place and not in the other.
Most of what makes an image wrong for a platform can be read from the file itself: pixel size, aspect ratio, byte size, file type, transparency and, for marketplace photos, how white the background is. We built a checker for this in PiCraft (picraft.ai). It runs entirely in the browser and nothing is uploaded. This post covers how it reads each of those things, and where it is only estimating.
What the checker needs from a file
Six values:
- Width and height in pixels
- Size in bytes
- File type
- Share of transparent pixels
- Share of the border that is pure white
- How much of the frame the non-white content spans
The first three are read from the file. The last three come from a reduced copy of the image, so they are estimates.
Pixels and bytes: let the browser decode it
URL.createObjectURL gives you a URL for a local File without reading or sending it anywhere. Load that URL into an Image and naturalWidth and naturalHeight are the pixel size. The byte size is file.size.
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("The browser could not open this file."));
image.src = url;
});
}
async function readImage(file: File) {
const url = URL.createObjectURL(file);
try {
const image = await loadImage(url);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (!width || !height) throw new Error("This file has no pixel size, so it cannot be checked.");
return { url, width, height, bytes: file.size, image };
} catch (error) {
URL.revokeObjectURL(url); // on success the URL stays alive for the preview
throw error;
}
}
File type: we trust the MIME type and the extension
The checker decides the format from file.type and falls back to the extension when the type is empty:
function detectFormat(file: File) {
const type = file.type.toLowerCase();
const extension = file.name.toLowerCase().split(".").pop() ?? "";
if (type === "image/jpeg" || (!type && (extension === "jpg" || extension === "jpeg"))) return "jpg";
if (type === "image/png" || (!type && extension === "png")) return "png";
// webp, gif and tiff work the same way; anything else is "other"
return "other";
}
This is cheap, and it is wrong for a file that was renamed from .jpg to .png with the type missing or edited. If that matters to you, read the first bytes instead. The checker does not do this yet; this is what it would look like:
async function sniffFormat(file: File): Promise<"jpg" | "png" | "gif" | "webp" | "tiff" | "other"> {
const b = new Uint8Array(await file.slice(0, 12).arrayBuffer());
const ascii = (from: number, to: number) => String.fromCharCode(...b.slice(from, to));
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpg";
if (b[0] === 0x89 && ascii(1, 4) === "PNG") return "png";
if (ascii(0, 3) === "GIF") return "gif";
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "webp";
const little = b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a && b[3] === 0;
const big = b[0] === 0x4d && b[1] === 0x4d && b[2] === 0 && b[3] === 0x2a;
if (little || big) return "tiff";
return "other";
}
Looking at the pixels without reading all of them
Transparency, a white background and "does the product fill the frame" all need pixel values. Looping over every pixel of a 4000×3000 photo is more work than these checks need, so the checker draws the image onto a canvas whose longest side is at most 400 px and reads that.
const SAMPLE_SIDE = 400;
function samplePixels(image: HTMLImageElement, width: number, height: number) {
const scale = Math.min(1, SAMPLE_SIDE / Math.max(width, height));
const sw = Math.max(1, Math.round(width * scale));
const sh = Math.max(1, Math.round(height * scale));
const canvas = document.createElement("canvas");
canvas.width = sw;
canvas.height = sh;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return { transparentShare: 0, whiteBorderShare: 0, fill: 0 };
context.drawImage(image, 0, 0, sw, sh);
const data = context.getImageData(0, 0, sw, sh).data;
const ring = Math.max(1, Math.round(Math.min(sw, sh) * 0.01)); // the outer 1% is "the border"
let transparent = 0, border = 0, whiteBorder = 0;
let minX = sw, maxX = -1, minY = sh, maxY = -1;
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
const i = (y * sw + x) * 4;
const alpha = data[i + 3];
if (alpha < 250) transparent++;
// Composite on white, the way the pixel would look on a white page.
const mix = (channel: number) => (channel * alpha + 255 * (255 - alpha)) / 255;
const lowest = Math.min(mix(data[i]), mix(data[i + 1]), mix(data[i + 2]));
if (lowest < 245) { // not white: part of the content
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (x < ring || y < ring || x >= sw - ring || y >= sh - ring) {
border++;
if (lowest >= 250) whiteBorder++;
}
}
}
const fill = maxX < 0 ? 0 : Math.max((maxX - minX + 1) / sw, (maxY - minY + 1) / sh);
return {
transparentShare: transparent / (sw * sh),
whiteBorderShare: border === 0 ? 0 : whiteBorder / border,
fill,
};
}
Two choices in there are worth explaining.
Transparent pixels count as white. A product cutout with a transparent background looks white on a white page, so the white-border test composites each pixel on white first. Fully transparent pixels come back from getImageData as (0, 0, 0, 0), so without that step they would look black and the test would fail for exactly the files that are fine.
Fill is a bounding box, not an area. "The product fills at least 85% of the frame" is the kind of rule marketplaces state but do not define. The checker measures how far the non-white content spreads across the frame, the larger of width and height. It is a guide. The output says so ("estimate"), and it does not claim to know how any marketplace measures it.
The cost of the 400 px copy is that very thin details can disappear: a faint shadow a pixel or two wide at full size is gone. For pass or fail on pixel size, ratio and bytes that does not matter, because those use the real file. It matters only for the three pixel-based values. The two that are judgement calls, the white background and the fill, are labelled "estimate" in the results.
Rules as data, checks as pure functions
Every platform rule is a preset in a JSON file, and the checks are pure functions of (preset, imageInfo). The same file runs on the server (to render the preset table) and in the browser (to run the checks), so it cannot import anything server-only.
A preset is a handful of optional fields. The YouTube thumbnail looks like this:
{
"id": "youtube-thumbnail",
"width": 1280,
"height": 720,
"minWidth": 640,
"aspect": [16, 9],
"formats": ["jpg", "png"],
"warnBytes": 2097152,
"maxBytes": 52428800,
"bytesNote": "YouTube allows 2 MB from its mobile app and 50 MB from a computer."
}
warnBytes and maxBytes are the answer to the opening problem. A limit that depends on which device you upload from cannot be a single pass or fail. Over 2 MB is a warning, with the note that the app has the stricter limit, and over 50 MB is a failure.
The other thresholds are plain numbers in the same style:
- Aspect ratio: off by more than 1% is a warning, more than 5% is a failure, and the message says how much to crop and from which edges.
- Print resolution:
dpi = min(width / frameWidthInches, height / frameHeightInches). At least 299 passes, 150 to 299 warns, below that fails. - White background: at least 98% of border pixels pure white passes, 90% to 98% warns.
- Transparency: at least 1% of pixels transparent.
Print presets carry the bleed. A US business card is 3.5×2 in, and with 0.125 in of bleed per edge the file is 3.75×2.25 in, which is 1125×675 px at 300 DPI.
What it does not do
- It does not read DPI metadata. DPI is worked out from the pixel size and the print size.
- It does not check color mode (RGB or CMYK), color profiles, or fonts in PDFs.
- It cannot see text, logos or watermarks, which marketplaces forbid on main images.
- It labels the white-background and fill results "estimate", because they come from the 400 px copy.
Every preset links to the size guide behind it, and the guide cites the official page the numbers came from, so you can check a rule before you trust it: picraft.ai/tools/image-size-checker. It is free, needs no account and uploads nothing.
If you have a rule that is missing or out of date, tell us in the comments. Platforms change their numbers.
This article was drafted with help from an AI assistant and edited by a person at PiCraft. The code excerpts come from the checker's source, and the YouTube limits are from YouTube Help (checked 2026-10-23).

Top comments (0)