{"record":{"id":"82abe3eeb3bf3e22","repo":"antiwork/gumroad","slug":"image-must-be-at-least-600x600px","errorCode":null,"errorMessage":"Image must be at least 600x600px.","messagePattern":"Image must be at least 600x600px\\.","errorType":"validation","errorClass":"ValidationError","httpStatus":null,"severity":"warning","filePath":"app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx","lineNumber":41,"sourceCode":"const MEGABYTE = 1024 * 1024;\nconst MAX_FILE_SIZE = 5 * MEGABYTE;\nexport class ValidationError extends Error {\n  constructor(message = \"Invalid file type.\") {\n    super(message);\n  }\n}\n\nconst validateFile = async (file: File) => {\n  if (!FileUtils.isFileNameExtensionAllowed(file.name, ALLOWED_EXTENSIONS)) throw new ValidationError();\n\n  if (file.size > MAX_FILE_SIZE)\n    throw new ValidationError(\"Could not process your thumbnail, please upload an image with size smaller than 5 MB.\");\n\n  const dimensions = await getImageDimensionsFromFile(file).catch(() => null);\n  if (!dimensions) throw new ValidationError();\n  if (dimensions.height !== dimensions.width) throw new ValidationError(\"Image must be square.\");\n\n  if (dimensions.height < MIN_SIDE_DIMENSION) throw new ValidationError(\"Image must be at least 600x600px.\");\n};\n\nexport const coverUrlForThumbnail = (covers: AssetPreview[]) =>\n  covers.find((cover) => cover.type === \"image\" || cover.type === \"unsplash\")?.url ?? null;\n\nexport const ThumbnailEditor = ({\n  covers,\n  thumbnail,\n  setThumbnail,\n  permalink,\n  nativeType,\n}: {\n  covers: AssetPreview[];\n  thumbnail: Thumbnail | null;\n  setThumbnail: (thumbnail: Thumbnail | null) => void;\n  permalink: string;\n  nativeType: ProductNativeType;\n}) => {","sourceCodeStart":23,"sourceCodeEnd":59,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx#L23-L59","documentation":"Client-side pre-upload validation in Gumroad's product ThumbnailEditor. Before a selected file is handed to ActiveStorage direct upload, validateFile() decodes it in the browser and enforces, in order: allowed extension, size under 5 MB, decodable image, square aspect, and finally both sides >= 600px (MIN_SIDE_DIMENSION). This error is the last gate: the image is square but its height/width is below 600px, which would render blurry product cards and covers.","triggerScenarios":"A user picks a square raster image smaller than 600x600 (e.g. a 256x256 logo or 512x512 export) in the product thumbnail editor; getImageDimensionsFromFile resolves with height < 600 and validateFile throws ValidationError('Image must be at least 600x600px.') before any DirectUpload begins. Non-square images fail one line earlier with 'Image must be square.', so this exact message implies a square-but-small file.","commonSituations":"Small logo/avatar artwork exported at native size, low-DPI screenshots, icons or favicons reused as covers, and HEIC/EXIF-rotated files whose decoded dimensions differ from the OS preview. Also developers reusing ThumbnailEditor with a pipeline that downsizes images below 600px.","solutions":["Re-export or resize the image to a square at least 600x600 (1024x1024 or larger recommended) and re-select it.","If the file is HEIC or an unusual container, convert it to PNG/JPG first so the browser decodes its true dimensions.","If you maintain this code and need a different floor, change MIN_SIDE_DIMENSION in ThumbnailEditor.tsx together with any server-side cover pipeline expectation.","Confirm the file is a real image: an undecodable file throws the generic 'Invalid file type.' ValidationError instead, which has a different fix."],"exampleFix":"# before: 512x512.png selected -> 'Image must be at least 600x600px.'\n# after: resize to a >=600px square\nmagick icon_512.png -resize 1200x1200 icon_1200.png","handlingStrategy":"validation","validationCode":"import { getImageDimensionsFromFile } from \"$app/utils/image\";\n\nconst MIN_SIDE = 600;\nconst thumbnailDimensionError = async (file: File): Promise<string | null> => {\n  const dims = await getImageDimensionsFromFile(file).catch(() => null);\n  if (!dims) return \"Not a readable image.\";\n  if (dims.width !== dims.height) return \"Image must be square.\";\n  if (dims.height < MIN_SIDE) return `Image must be at least ${MIN_SIDE}x${MIN_SIDE}px.`;\n  return null;\n};","typeGuard":"const isSquareAtLeast600 = (d: { width: number; height: number } | null): d is { width: number; height: number } =>\n  !!d && d.width === d.height && d.height >= 600;","tryCatchPattern":"try {\n  await validateFile(file);\n} catch (e) {\n  if (e instanceof ValidationError) {\n    showAlert(e.message, \"error\"); // message is already user-facing\n    return;\n  }\n  throw e;\n}","preventionTips":["Export thumbnails at 1024x1024 or larger so future floor increases are covered.","Run dimension validation on file select, not on submit, so feedback is immediate.","Keep MIN_SIDE_DIMENSION and the server-side cover pipeline requirements in sync when changing the floor."],"tags":["image-upload","client-side-validation","file-validation","thumbnails","products"],"backgroundTag":"image-dimension-validation","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}