OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 9b2dcc5

0.2.0: the mip pyramid — levels, and the selection bridges make from them
record-vocabulary §3.5 and SOCIAL-MODEL §6.6.1 specify a mip pyramid so a bridge can pick a size fitting another network's ceiling. This is that model, and it lives here because it IS record vocabulary: the peer writes a pointer, both bridges read one, and a copy per consumer is how this monorepo ended up with six copies of one signing format. A ladder that drifts between writer and reader is worse still — each side stays internally consistent while disagreeing about what a level means. The pyramid IS the object. One pointer, N oids, no manifest file and no variant-of edge, because the levels were never separate objects. Every level is a whole file with its own oid, which sidesteps the overlay's whole-file hashing instead of fighting it: sparse fetch of one blob is impossible, but sparseness BY LEVEL is free. ⛔ NEVER UPSCALE. A level is a floor: a 300px picture generates nothing below it and has exactly one level. hypernote's HLS ladder already encodes this by filtering `l.height <= srcHeight`; the same filter, stated once, for images and video both. ⛔ `by` IS LOAD-BEARING. A pyramid is allowed to be partial and a level may be derived later by a bridge or a well-resourced peer, so each level records who made it. Deriving is not authoring — §8's "never re-encode a record you did not author" cost two of wj62's posts, and without this the author's bytes and somebody else's re-encode are the same thing. ⛔ selectLevel returns a REASON, never silence. "No level fits your ceiling" and "the fetch failed" are different facts (§6.6.4 #1), and it reports the level it chose even on success — a bridge that quietly serves a smaller picture has altered what was published. Levels are chosen against real network ceilings rather than powers of two: bsky takes 2,000,000 for a post image and 1,000,000 for a profile picture. 8/8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9b2dcc5e33da260d071b7e5e3da4832fbc890f90
by plankatron <fleeky@gmail.com> · 2026-09-09T23:59:16.000Z

Changed files (3) +230 -1

lib/levels.js +141 -0
……@@ -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.
32export 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.
41export 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
47export 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 */
61export 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 */
74export 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.
97export const AUTHOR = 'author'
98export const derivedBy = (who) => 'derived:' + String(who || 'unknown').slice(0, 32)
99
100export 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 */
121export 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
141export default { IMAGE_LEVELS, VIDEO_LEVELS, planLevels, scaleTo, makePointer, selectLevel, AUTHOR, derivedBy }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-markdown",
3 "version": "0.1.6",
3 "version": "0.2.0",
44 "description": "The P2P-native markdown record layer for favela. A post is a markdown document; atproto and ActivityPub are projections derived from it on read.",
55 "type": "module",
66 "license": "MIT",
test/levels.test.js +88 -0
……@@ -0,0 +1,88 @@
1// The mip pyramid. Pure, so all of this runs with no overlay, no socket and no encoder.
2import test from 'brittle'
3import {
4 planLevels, scaleTo, makePointer, selectLevel, IMAGE_LEVELS, VIDEO_LEVELS, AUTHOR, derivedBy
5} from '../lib/levels.js'
6
7// ⛔ §6.6.1 #2 — a level is a FLOOR, not a guarantee.
8test('★ never upscale — a small asset has fewer levels, not padded ones', (t) => {
9 t.is(planLevels({ mimeType: 'image/png', width: 4000, height: 3000 }).length, IMAGE_LEVELS.length,
10 'a big picture gets the whole ladder below it')
11 t.alike(planLevels({ mimeType: 'image/png', width: 300, height: 200 }), [],
12 '⛔ a 300px picture generates NOTHING below it — the source is its only level')
13 const mid = planLevels({ mimeType: 'image/png', width: 800, height: 600 }).map((l) => l.name)
14 t.alike(mid, ['small', 'thumb'], 'and a middling one gets only the levels genuinely smaller')
15})
16
17test('video ladders on height, the way every encoder ladder is expressed', (t) => {
18 t.alike(planLevels({ mimeType: 'video/mp4', width: 1920, height: 1080 }).map((l) => l.name),
19 ['720p', '480p'], 'never a level at or above the source height')
20 t.alike(planLevels({ mimeType: 'video/mp4', width: 640, height: 360 }), [], 'a small clip has one level')
21 t.ok(VIDEO_LEVELS.every((l) => l.bitrate && l.profile), 'each carries its encoder settings')
22})
23
24test('scaling preserves aspect and never yields zero', (t) => {
25 t.alike(scaleTo({ width: 4000, height: 2000 }, 2000), { width: 2000, height: 1000 })
26 t.alike(scaleTo({ width: 100, height: 50 }, 2000), { width: 100, height: 50 }, 'no upscale')
27 const tiny = scaleTo({ width: 3000, height: 4 }, 320)
28 t.ok(tiny.height >= 1, 'a wafer-thin image still has a height')
29})
30
31test('the pointer carries the levels — largest first, base is level 0', (t) => {
32 const p = makePointer({
33 mimeType: 'image/png',
34 size: 900,
35 levels: [
36 { oid: 'c', w: 320, h: 240, size: 50 },
37 { oid: 'a', w: 2000, h: 1500, size: 900 },
38 { oid: 'b', w: 640, h: 480, size: 200 }
39 ]
40 })
41 t.alike(p.levels.map((l) => l.oid), ['a', 'b', 'c'], 'largest first')
42 t.is(p.levels[0].by, AUTHOR, 'and authored unless said otherwise')
43})
44
45// ⛔ §6.6.4 #2/#3 — deriving is not authoring.
46test('⛔ a derived level says who derived it', (t) => {
47 const p = makePointer({
48 mimeType: 'image/png',
49 size: 900,
50 levels: [
51 { oid: 'a', w: 2000, h: 1500, size: 900 },
52 { oid: 'b', w: 640, h: 480, size: 200, by: derivedBy('ap-bridge') }
53 ]
54 })
55 t.is(p.levels[0].by, AUTHOR, "the author's own bytes")
56 t.is(p.levels[1].by, 'derived:ap-bridge', '⛔ or an author’s bytes and a re-encode become the same thing')
57})
58
59// ⛔ Bridges SELECT.
60test('★ selectLevel picks the biggest that fits the ceiling', (t) => {
61 const p = makePointer({
62 mimeType: 'image/png',
63 size: 3000000,
64 levels: [
65 { oid: 'a', w: 2000, h: 1500, size: 3000000 },
66 { oid: 'b', w: 1280, h: 960, size: 900000 },
67 { oid: 'c', w: 640, h: 480, size: 200000 }
68 ]
69 })
70 t.is(selectLevel(p, { maxBytes: 2000000 }).level.oid, 'b', 'the base is too heavy, the next one fits')
71 t.is(selectLevel(p, { maxBytes: 1000000 }).level.oid, 'b', 'bsky post ceiling')
72 t.is(selectLevel(p, { maxBytes: 500000 }).level.oid, 'c', 'a profile ceiling reaches further down')
73 t.is(selectLevel(p, { maxBytes: Infinity }).level.oid, 'a', 'and with no ceiling, the author’s own bytes')
74})
75
76// ⛔ §6.6.4 #1 — a clear absence, never a 404.
77test('⛔ when nothing fits it SAYS so rather than returning silence', (t) => {
78 const p = makePointer({ mimeType: 'image/png', size: 9, levels: [{ oid: 'a', w: 100, h: 100, size: 5000000 }] })
79 const r = selectLevel(p, { maxBytes: 1000 })
80 t.absent(r.level)
81 t.ok(r.reason.includes('no level fits'), 'the reason distinguishes it from a failed fetch: ' + r.reason)
82 t.ok(selectLevel({ levels: [] }, {}).reason.includes('no levels'), 'and so does an asset with none')
83})
84
85test('the chosen level is always reported, even on success', (t) => {
86 const p = makePointer({ mimeType: 'image/png', size: 9, levels: [{ oid: 'a', w: 100, h: 100, size: 9 }] })
87 t.ok(selectLevel(p, { maxBytes: 100 }).reason, '⛔ a bridge that quietly serves a smaller picture has altered what was published')
88})