OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / static / levels.js

53 lines · javascript ref:
// The mip ladder, browser side. Pure — the canvas work that acts on this lives in composer.js.
//
// ⛔ THIS IS WHERE THE PYRAMID IS BUILT. SOCIAL-MODEL §6.6.1: downsampling happens ONCE, at
// authoring, deterministically — like generating mips at asset build time — so a bridge SELECTS a
// level instead of re-encoding per delivery. §6.6.5 measured that the encoder for images is
// `<canvas>`, already in the runtime, costing no binary size; favela-android is a WebView shell, so
// the handset gets this same path.
//
// ⚠ Mirrors favela-peer/lib/levels.js. Two copies of a ladder is the shape this monorepo has been
// bitten by before, and the alternative here is worse: the peer runs under Bare and this runs in a
// browser with no module server between them. The ladder is data, the test suites assert the same
// numbers, and the peer is authoritative if they ever disagree.

export const IMAGE_LEVELS = [
  { name: 'full', edge: 2000, quality: 0.92 },
  { name: 'large', edge: 1280, quality: 0.86 },
  { name: 'small', edge: 640, quality: 0.82 },
  { name: 'thumb', edge: 320, quality: 0.78 }
]

/** ⛔ NEVER UPSCALE — a level is a floor. An image smaller than the lowest level has one level. */
export function planLevels (width, height) {
  const longest = Math.max(Number(width) || 0, Number(height) || 0)
  if (!longest) return []
  return IMAGE_LEVELS.filter((l) => l.edge < longest)
}

export function scaleTo (width, height, edge) {
  const longest = Math.max(width, height)
  if (!longest || edge >= longest) return { width, height }
  const s = edge / longest
  return { width: Math.max(1, Math.round(width * s)), height: Math.max(1, Math.round(height * s)) }
}

/**
 * Render one level. Resolves `{ blob, width, height }` or null.
 *
 * ⚠ JPEG for every derived level regardless of the source type: they exist to fit a ceiling, and
 * PNG re-encoding a photograph produces a level LARGER than the original, which defeats the point.
 * The base level is never touched — it is the author's own bytes and is uploaded as they are.
 */
export async function renderLevel (bitmap, edge, quality) {
  const { width, height } = scaleTo(bitmap.width, bitmap.height, edge)
  const canvas = document.createElement('canvas')
  canvas.width = width
  canvas.height = height
  const ctx = canvas.getContext('2d')
  if (!ctx) return null
  ctx.drawImage(bitmap, 0, 0, width, height)
  const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality))
  return blob ? { blob, width, height } : null
}