OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / web / static / resize-plan.js

67 lines · javascript ref:
// Should this picture be re-encoded before upload, and to what size?
//
// ⛔ THE LIMIT IS THE LEXICON'S, NOT OURS. app.bsky.embed.images specifies
//
//   "blob": { "accept": ["image/*"], "maxSize": 2000000 }
//
// so 2,000,000 bytes is the ceiling, with at most 4 images per post and `alt` required. compose.js used
// to enforce 1024*1024 — about half — with no justification anywhere in the source, and it REFUSED
// oversize files rather than shrinking them. Once the phone's composer could open the camera (2026-08-18)
// that made "attach a photo" a dead end by construction: a phone camera JPEG is 3–12 MB, so it failed
// both the old cap and the real one. Raising the number alone fixes nothing; the picture has to shrink.
//
// Pure and DOM-free on purpose. The canvas work that acts on this lives in compose.js, where a browser is
// required to test it; the decision — which is where the arithmetic errors live — is tested under node
// (test/resize-plan.test.mjs).

// The protocol ceiling, quoted rather than chosen.
export const MAX_BYTES = 2000000

// Longest edge after scaling. Not from the lexicon — there is no dimension limit in atproto — but a
// 4000px photo carries far more detail than any feed renders, and shrinking pixels is what makes the
// byte ceiling reachable without destroying quality through compression alone.
export const MAX_EDGE = 2000

/**
 * @param {{width:number, height:number, bytes:number, type:string}} file
 * @returns {{needed:boolean, width:number, height:number, reason:string}}
 */
export function resizePlan ({ width = 0, height = 0, bytes = 0, type = '' } = {}) {
  const isImage = String(type).startsWith('image/')
  // Canvas re-encoding a non-image produces a broken picture silently, so decline and let the caller
  // reject it with a real message.
  if (!isImage) return { needed: false, width, height, reason: 'not an image' }

  const longest = Math.max(width, height)
  const overEdge = longest > MAX_EDGE && longest > 0
  const overBytes = bytes > MAX_BYTES

  if (!overEdge && !overBytes) return { needed: false, width, height, reason: 'within limits' }

  // Scale only when the picture is genuinely oversized in PIXELS. When it is merely heavy — a large JPEG
  // at modest dimensions — keep every pixel and let re-encoding do the work; throwing away detail to fix
  // a compression problem degrades the image for no reason.
  if (!overEdge) return { needed: true, width, height, reason: 'over the byte ceiling' }

  const scale = MAX_EDGE / longest
  return {
    needed: true,
    width: Math.max(1, Math.round(width * scale)),
    height: Math.max(1, Math.round(height * scale)),
    reason: overBytes ? 'oversized and over the byte ceiling' : 'oversized'
  }
}

/**
 * The `aspectRatio` field of app.bsky.embed.images#image — what bsky lays the image out with, and what
 * we have never sent, so every favela image has been laid out from a guess.
 *
 * Null rather than a zero ratio when the dimensions are unknown: no claim beats a wrong one.
 */
export function aspectRatioOf (width, height) {
  const w = Math.round(Number(width) || 0)
  const h = Math.round(Number(height) || 0)
  if (w <= 0 || h <= 0) return null
  return { width: w, height: h }
}