{"record":{"id":"e14299fc6bfd8119","repo":"antiwork/gumroad","slug":"image-must-be-square","errorCode":null,"errorMessage":"Image must be square.","messagePattern":"Image must be square\\.","errorType":"validation","errorClass":"ValidationError","httpStatus":null,"severity":"warning","filePath":"app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx","lineNumber":39,"sourceCode":"\nconst MIN_SIDE_DIMENSION = 600;\nconst 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;","sourceCodeStart":21,"sourceCodeEnd":57,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx#L21-L57","documentation":"Fourth check in validateFile: after a successful decode, height !== width throws ValidationError('Image must be square.') — product thumbnails are required to be perfectly square, independently of the 600px minimum side enforced on the next line. The check runs on actual decoded pixel dimensions, not metadata.","triggerScenarios":"Uploading any non-square image — typical 3:2 or 16:9 camera/screenshot output, a 1080x1350 portrait, a banner-style 1500x500 crop — anything where height and width differ by even one pixel.","commonSituations":"Product photos straight off a phone camera; marketing banners repurposed as thumbnails; artwork cropped to the product's aspect ratio rather than 1:1; social images exported at 1200x630.","solutions":["Crop (not stretch) to square in any editor — center-crop is usually right for product shots.","Automate: `magick input.jpg -gravity center -crop 1:1 +repage square.jpg`.","Crop to at least 600x600 while squaring, or the next check (MIN_SIDE_DIMENSION) will reject it.","Some editors' export presets have a 1:1 option — use it once and reuse the preset."],"exampleFix":"// before — user uploads 1600x900 and only learns after upload\n// after — offer an automatic center-crop instead of rejecting\nconst dims = await getImageDimensionsFromFile(file);\nif (dims && dims.height !== dims.width) {\n  const side = Math.min(dims.width, dims.height);\n  file = await cropToSquare(file, side); // center-crop via canvas\n}\nconst dimensions = await getImageDimensionsFromFile(file).catch(() => null);","handlingStrategy":"validation","validationCode":"const isSquare = async (file: File): Promise<boolean> => {\n  const dims = await getImageDimensionsFromFile(file).catch(() => null);\n  return dims !== null && dims.width === dims.height;\n};\n\nif (!(await isSquare(file))) {\n  const proceed = await confirm('Image is not square — center-crop it automatically?');\n  if (proceed) file = await cropToSquare(file);\n  else return;\n}","typeGuard":"const isValidationError = (e: unknown): e is ValidationError => e instanceof ValidationError;","tryCatchPattern":"try {\n  await validateFile(file);\n} catch (e) {\n  if (e instanceof ValidationError) {\n    const actionable = e.message; // 'Image must be square.' / '...at least 600x600px.' etc.\n    showAlert(actionable, 'error');\n    return;\n  }\n  throw e;\n}","preventionTips":["Communicate the 1:1 requirement (and the 600px minimum) next to the picker so uploads are prepared correctly the first time.","Offer an automatic center-crop; rejecting outright forces users into external editors.","Crop, never stretch — stretching distorts the product photo and passes the height === width check.","Use an editor preset at 1200x1200 or 2000x2000: square, above the minimum, and safely under the 5 MB cap."],"tags":["file-validation","thumbnail","dimensions","square-crop","client-side"],"backgroundTag":"image-dimension-validation","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}