// 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 }
}