| … | … | @@ -0,0 +1,141 @@ |
| 1 | // levels.js — the mip pyramid. Pure: no I/O, no clock, no throw. |
| 2 | // |
| 3 | // ⛔ IT LIVES HERE BECAUSE IT IS RECORD VOCABULARY. `spec/record-vocabulary.md` §3.5 defines the |
| 4 | // pointer; the peer writes one, and BOTH bridges read one to choose a level. A copy per consumer is |
| 5 | // how this monorepo ended up with six copies of one signing format, three of which ate the first |
| 6 | // character of a hash — and a ladder that drifts between the writer and the reader is worse than |
| 7 | // that, because each side stays internally consistent while disagreeing about what a level means. |
| 8 | // |
| 9 | // ⛔ THE PYRAMID IS THE OBJECT (SOCIAL-MODEL §6.6.1). A texture in a game is not a file plus a set |
| 10 | // of related smaller files; it is one asset containing a pyramid, and the consumer picks a level by |
| 11 | // how much of the screen it covers. Media works the same way here. There is no `variant-of` edge |
| 12 | // because the levels were never separate objects — the relation is the structure of the asset, not |
| 13 | // something asserted and then maintained. |
| 14 | // |
| 15 | // ⛔ AND IT SIDESTEPS THE OVERLAY'S LIMIT RATHER THAN FIGHTING IT. hyper-overlay addresses a file by |
| 16 | // a whole-file hash, so a prefix cannot be authenticated and sparse fetch of ONE blob is impossible. |
| 17 | // A pyramid needs no prefix: every level is a whole file with its own oid, which is exactly what the |
| 18 | // overlay already does well. Fetch the pointer, choose a level, fetch that oid. Sparseness BY LEVEL. |
| 19 | // |
| 20 | // ⛔ BRIDGES SELECT, THEY DO NOT DOWNSAMPLE. Encoding happens once, at authoring, deterministically — |
| 21 | // like generating mips at asset build time — instead of per delivery on every bridge. `selectLevel` |
| 22 | // is that choice, and it reports which level it picked so the loss is never silent. |
| 23 | // |
| 24 | // ⚠ Cost is the mip cost and it is bounded: 1 + ¼ + 1/16 + … = 4/3 of the base. Known in advance, |
| 25 | // unlike an open-ended set of variants. |
| 26 | |
| 27 | // ── the ladders ─────────────────────────────────────────────────────────────────────────────── |
| 28 | // |
| 29 | // ⚠ Levels are chosen against REAL NETWORK CEILINGS, not powers of two (§6.6.1, "where the analogy |
| 30 | // breaks" #1). bsky takes 2,000,000 bytes for a post image and 1,000,000 for a profile picture; |
| 31 | // Mastodon resizes on receipt and is not the binding constraint. The edges below bracket those. |
| 32 | export const IMAGE_LEVELS = [ |
| 33 | { name: 'full', edge: 2000, targetBytes: 2000000 }, |
| 34 | { name: 'large', edge: 1280, targetBytes: 1000000 }, |
| 35 | { name: 'small', edge: 640, targetBytes: 300000 }, |
| 36 | { name: 'thumb', edge: 320, targetBytes: 80000 } |
| 37 | ] |
| 38 | |
| 39 | // The bitrate ladder IS a mip chain for video — hypernote's HLS_LEVELS, which the spec names as the |
| 40 | // one we already ship. Same shape, same never-upscale filter. |
| 41 | export const VIDEO_LEVELS = [ |
| 42 | { name: '1080p', edge: 1080, bitrate: '5000k', maxrate: '6000k', profile: 'high' }, |
| 43 | { name: '720p', edge: 720, bitrate: '2500k', maxrate: '3000k', profile: 'main' }, |
| 44 | { name: '480p', edge: 480, bitrate: '1000k', maxrate: '1200k', profile: 'baseline' } |
| 45 | ] |
| 46 | |
| 47 | export const laddersFor = (mimeType) => |
| 48 | /^video\//.test(String(mimeType || '')) ? VIDEO_LEVELS |
| 49 | : /^image\//.test(String(mimeType || '')) ? IMAGE_LEVELS |
| 50 | : [] |
| 51 | |
| 52 | /** |
| 53 | * Which levels should be GENERATED for a source asset. |
| 54 | * |
| 55 | * ⛔ NEVER UPSCALE (§6.6.1 #2). A level is a floor, not a guarantee: an asset smaller than the |
| 56 | * lowest level has exactly one level, and the pointer must not imply levels it lacks. hypernote |
| 57 | * already encodes this by filtering `l.height <= srcHeight`; this is the same filter, stated once. |
| 58 | * |
| 59 | * The source itself is always level 0 — the author's own bytes, unaltered. |
| 60 | */ |
| 61 | export function planLevels ({ mimeType = '', width = 0, height = 0 } = {}) { |
| 62 | const ladder = laddersFor(mimeType) |
| 63 | if (!ladder.length) return [] |
| 64 | const longest = Math.max(Number(width) || 0, Number(height) || 0) |
| 65 | // For video the ladder is keyed on HEIGHT, which is how every encoder ladder is expressed. |
| 66 | const source = /^video\//.test(mimeType) ? (Number(height) || 0) : longest |
| 67 | if (!source) return [] |
| 68 | return ladder.filter((l) => l.edge < source) |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Scale a source to a level's edge, preserving aspect. Whole pixels, never zero. |
| 73 | */ |
| 74 | export function scaleTo ({ width, height }, edge) { |
| 75 | const w = Number(width) || 0 |
| 76 | const h = Number(height) || 0 |
| 77 | const longest = Math.max(w, h) |
| 78 | if (!longest || edge >= longest) return { width: w, height: h } |
| 79 | const s = edge / longest |
| 80 | return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) } |
| 81 | } |
| 82 | |
| 83 | // ── the pointer ─────────────────────────────────────────────────────────────────────────────── |
| 84 | // |
| 85 | // ⛔ THERE IS NO MANIFEST FILE (record-vocabulary §3.5). The pointer carries the levels — one |
| 86 | // pointer, N oids, zero extra artifacts. A manifest file would be a third artifact for a job the |
| 87 | // pointer already does, and the only per-asset duplicate in a design whose whole point is that |
| 88 | // identical bytes are stored once however many records reference them. |
| 89 | // |
| 90 | // { mimeType, size, levels: [ { oid, w, h, size, by }, … ] } |
| 91 | // |
| 92 | // ⛔ `by` IS LOAD-BEARING, not a nicety (§6.6.4 #2/#3). A pyramid is allowed to be PARTIAL, and a |
| 93 | // level may be derived later by a bridge or a well-resourced peer. Deriving is not authoring: a peer |
| 94 | // adding a level has produced a derived artifact and must say so, or an author's bytes and somebody |
| 95 | // else's re-encode become indistinguishable. §8's "never re-encode a record you did not author" |
| 96 | // already cost two of wj62's posts. |
| 97 | export const AUTHOR = 'author' |
| 98 | export const derivedBy = (who) => 'derived:' + String(who || 'unknown').slice(0, 32) |
| 99 | |
| 100 | export function makePointer ({ mimeType, size, levels = [] }) { |
| 101 | return { |
| 102 | mimeType: String(mimeType || 'application/octet-stream'), |
| 103 | size: Number(size) || 0, |
| 104 | // Largest first, so "the biggest that fits" is a scan from the front and the base is level 0. |
| 105 | levels: [...levels].sort((a, b) => (b.w * b.h) - (a.w * a.h)).map((l) => ({ |
| 106 | oid: l.oid, w: Number(l.w) || 0, h: Number(l.h) || 0, size: Number(l.size) || 0, by: l.by || AUTHOR |
| 107 | })) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * The level nearest a destination's ceiling. `{ level, reason }` — level is null when none fits. |
| 113 | * |
| 114 | * ⛔ A CLEAR ABSENCE, NEVER A 404 (§6.6.4 #1). "This asset has no level under your ceiling" and "the |
| 115 | * fetch failed" are different facts and a consumer must be able to tell them apart, so this returns |
| 116 | * a reason rather than nothing. |
| 117 | * |
| 118 | * ⚠ It reports what it picked even on success, because §6.6.1 makes that the honesty requirement: |
| 119 | * a bridge that quietly serves a smaller picture has altered what the author published. |
| 120 | */ |
| 121 | export function selectLevel (pointer, { maxBytes = Infinity, maxEdge = Infinity } = {}) { |
| 122 | const levels = (pointer && Array.isArray(pointer.levels)) ? pointer.levels : [] |
| 123 | if (!levels.length) return { level: null, reason: 'this asset has no levels' } |
| 124 | for (const l of levels) { |
| 125 | const edge = Math.max(l.w || 0, l.h || 0) |
| 126 | if ((l.size || 0) <= maxBytes && (!edge || edge <= maxEdge)) { |
| 127 | const isBase = l === levels[0] |
| 128 | return { |
| 129 | level: l, |
| 130 | reason: isBase ? 'full size' : 'level ' + edge + 'px (' + l.size + ' bytes) chosen for a ceiling of ' + maxBytes |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | const smallest = levels[levels.length - 1] |
| 135 | return { |
| 136 | level: null, |
| 137 | reason: 'no level fits — the smallest is ' + (smallest.size || 0) + ' bytes against a ceiling of ' + maxBytes |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | export default { IMAGE_LEVELS, VIDEO_LEVELS, planLevels, scaleTo, makePointer, selectLevel, AUTHOR, derivedBy } |