// levels.js — the mip pyramid. Pure: no I/O, no clock, no throw.
//
// ⛔ IT LIVES HERE BECAUSE IT IS RECORD VOCABULARY. `spec/record-vocabulary.md` §3.5 defines the
// pointer; the peer writes one, and BOTH bridges read one to choose a level. A copy per consumer is
// how this monorepo ended up with six copies of one signing format, three of which ate the first
// character of a hash — and a ladder that drifts between the writer and the reader is worse than
// that, because each side stays internally consistent while disagreeing about what a level means.
//
// ⛔ THE PYRAMID IS THE OBJECT (SOCIAL-MODEL §6.6.1). A texture in a game is not a file plus a set
// of related smaller files; it is one asset containing a pyramid, and the consumer picks a level by
// how much of the screen it covers. Media works the same way here. There is no `variant-of` edge
// because the levels were never separate objects — the relation is the structure of the asset, not
// something asserted and then maintained.
//
// ⛔ AND IT SIDESTEPS THE OVERLAY'S LIMIT RATHER THAN FIGHTING IT. hyper-overlay addresses a file by
// a whole-file hash, so a prefix cannot be authenticated and sparse fetch of ONE blob is impossible.
// A pyramid needs no prefix: every level is a whole file with its own oid, which is exactly what the
// overlay already does well. Fetch the pointer, choose a level, fetch that oid. Sparseness BY LEVEL.
//
// ⛔ BRIDGES SELECT, THEY DO NOT DOWNSAMPLE. Encoding happens once, at authoring, deterministically —
// like generating mips at asset build time — instead of per delivery on every bridge. `selectLevel`
// is that choice, and it reports which level it picked so the loss is never silent.
//
// ⚠ Cost is the mip cost and it is bounded: 1 + ¼ + 1/16 + … = 4/3 of the base. Known in advance,
// unlike an open-ended set of variants.
// ── the ladders ───────────────────────────────────────────────────────────────────────────────
//
// ⚠ Levels are chosen against REAL NETWORK CEILINGS, not powers of two (§6.6.1, "where the analogy
// breaks" #1). bsky takes 2,000,000 bytes for a post image and 1,000,000 for a profile picture;
// Mastodon resizes on receipt and is not the binding constraint. The edges below bracket those.
export const IMAGE_LEVELS = [
{ name: 'full', edge: 2000, targetBytes: 2000000 },
{ name: 'large', edge: 1280, targetBytes: 1000000 },
{ name: 'small', edge: 640, targetBytes: 300000 },
{ name: 'thumb', edge: 320, targetBytes: 80000 }
]
// The bitrate ladder IS a mip chain for video — hypernote's HLS_LEVELS, which the spec names as the
// one we already ship. Same shape, same never-upscale filter.
export const VIDEO_LEVELS = [
{ name: '1080p', edge: 1080, bitrate: '5000k', maxrate: '6000k', profile: 'high' },
{ name: '720p', edge: 720, bitrate: '2500k', maxrate: '3000k', profile: 'main' },
{ name: '480p', edge: 480, bitrate: '1000k', maxrate: '1200k', profile: 'baseline' }
]
export const laddersFor = (mimeType) =>
/^video\//.test(String(mimeType || '')) ? VIDEO_LEVELS
: /^image\//.test(String(mimeType || '')) ? IMAGE_LEVELS
: []
/**
* Which levels should be GENERATED for a source asset.
*
* ⛔ NEVER UPSCALE (§6.6.1 #2). A level is a floor, not a guarantee: an asset smaller than the
* lowest level has exactly one level, and the pointer must not imply levels it lacks. hypernote
* already encodes this by filtering `l.height <= srcHeight`; this is the same filter, stated once.
*
* The source itself is always level 0 — the author's own bytes, unaltered.
*/
export function planLevels ({ mimeType = '', width = 0, height = 0 } = {}) {
const ladder = laddersFor(mimeType)
if (!ladder.length) return []
const longest = Math.max(Number(width) || 0, Number(height) || 0)
// For video the ladder is keyed on HEIGHT, which is how every encoder ladder is expressed.
const source = /^video\//.test(mimeType) ? (Number(height) || 0) : longest
if (!source) return []
return ladder.filter((l) => l.edge < source)
}
/**
* Scale a source to a level's edge, preserving aspect. Whole pixels, never zero.
*/
export function scaleTo ({ width, height }, edge) {
const w = Number(width) || 0
const h = Number(height) || 0
const longest = Math.max(w, h)
if (!longest || edge >= longest) return { width: w, height: h }
const s = edge / longest
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) }
}
// ── the pointer ───────────────────────────────────────────────────────────────────────────────
//
// ⛔ THERE IS NO MANIFEST FILE (record-vocabulary §3.5). The pointer carries the levels — one
// pointer, N oids, zero extra artifacts. A manifest file would be a third artifact for a job the
// pointer already does, and the only per-asset duplicate in a design whose whole point is that
// identical bytes are stored once however many records reference them.
//
// { mimeType, size, levels: [ { oid, w, h, size, by }, … ] }
//
// ⛔ `by` IS LOAD-BEARING, not a nicety (§6.6.4 #2/#3). A pyramid is allowed to be PARTIAL, and a
// level may be derived later by a bridge or a well-resourced peer. Deriving is not authoring: a peer
// adding a level has produced a derived artifact and must say so, or an author's bytes and somebody
// else's re-encode become indistinguishable. §8's "never re-encode a record you did not author"
// already cost two of wj62's posts.
export const AUTHOR = 'author'
export const derivedBy = (who) => 'derived:' + String(who || 'unknown').slice(0, 32)
export function makePointer ({ mimeType, size, levels = [] }) {
return {
mimeType: String(mimeType || 'application/octet-stream'),
size: Number(size) || 0,
// Largest first, so "the biggest that fits" is a scan from the front and the base is level 0.
levels: [...levels].sort((a, b) => (b.w * b.h) - (a.w * a.h)).map((l) => ({
oid: l.oid, w: Number(l.w) || 0, h: Number(l.h) || 0, size: Number(l.size) || 0, by: l.by || AUTHOR
}))
}
}
/**
* The level nearest a destination's ceiling. `{ level, reason }` — level is null when none fits.
*
* ⛔ A CLEAR ABSENCE, NEVER A 404 (§6.6.4 #1). "This asset has no level under your ceiling" and "the
* fetch failed" are different facts and a consumer must be able to tell them apart, so this returns
* a reason rather than nothing.
*
* ⚠ It reports what it picked even on success, because §6.6.1 makes that the honesty requirement:
* a bridge that quietly serves a smaller picture has altered what the author published.
*/
export function selectLevel (pointer, { maxBytes = Infinity, maxEdge = Infinity } = {}) {
const levels = (pointer && Array.isArray(pointer.levels)) ? pointer.levels : []
if (!levels.length) return { level: null, reason: 'this asset has no levels' }
for (const l of levels) {
const edge = Math.max(l.w || 0, l.h || 0)
if ((l.size || 0) <= maxBytes && (!edge || edge <= maxEdge)) {
const isBase = l === levels[0]
return {
level: l,
reason: isBase ? 'full size' : 'level ' + edge + 'px (' + l.size + ' bytes) chosen for a ceiling of ' + maxBytes
}
}
}
const smallest = levels[levels.length - 1]
return {
level: null,
reason: 'no level fits — the smallest is ' + (smallest.size || 0) + ' bytes against a ceiling of ' + maxBytes
}
}
export default { IMAGE_LEVELS, VIDEO_LEVELS, planLevels, scaleTo, makePointer, selectLevel, AUTHOR, derivedBy }