OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / c5b413b

0.0.49: build the pyramid at compose time
SOCIAL-MODEL §6.6.5 measured that the image encoder is `<canvas>` — in the runtime, costing no binary size — and that favela-android is a WebView shell, so the handset gets this same path. §6.6.1: downsample ONCE at authoring, deterministically, so a bridge selects instead of re-encoding per delivery. ⛔ PROGRESSIVE, like the @-picker beside it. With the script missing the form posts the original file as one part and the daemon stores one level — today's behaviour exactly, byte for byte. With it present the browser also renders the smaller rungs. ⛔ THE BASE IS THE AUTHOR'S OWN BYTES, uploaded untouched. Only derived rungs are re-encoded, as JPEG regardless of source type: they exist to fit a ceiling, and PNG re-encoding a photograph produces a level LARGER than the original. A rung that does not beat its source is dropped for the same reason. ⛔ NEVER UPSCALE — an image smaller than the lowest rung has one level. ⛔ And a failure while encoding must not lose the post: it falls through and sends what the form already holds, which is what a browser without this code sends anyway. The real dimensions travel too. bsky lays an image out from aspectRatio and we have never sent one, so every favela picture has been laid out from a guess. This is also why the toops screenshot was 1,044,866 raw bytes: favela-web has had a single-level encoder since August and favela-surface had none at all. 175/175. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c5b413be2d7321d637de846100f85b8b8baffdda
by plankatron <fleeky@gmail.com> · 2026-09-10T00:03:19.000Z

Changed files (4) +128 -3

lib/router.js +22 -2
……@@ -817,13 +817,33 @@ export async function route (req, res, ctx) {
817817 // copies what it keeps, so this temp file is ours to remove.
818818 const tmp = join(os.tmpdir(), 'favela-post-' + Date.now() + '-' + Math.random().toString(16).slice(2))
819819 fs.writeFileSync(tmp, file.bytes)
820 // ⛔ THE PYRAMID, when the composer built one. The base is the author's own bytes and always
821 // level 0; `level-N` parts are the smaller levels the browser rendered at compose time
822 // (SOCIAL-MODEL §6.6.1 — encode once at authoring, so bridges SELECT rather than re-encode).
823 //
824 // ⚠ Absent levels are the ordinary case, not a failure: a browser without the script, an
825 // image already smaller than the lowest rung, or a non-image all send exactly one part. That
826 // path is today's behaviour and stays byte-identical.
827 const tmps = [tmp]
828 const levels = [{ path: tmp, w: Number(form.fields['file-w']) || 0, h: Number(form.fields['file-h']) || 0 }]
829 for (let n = 0; ; n++) {
830 const part = form.files['level-' + n]
831 if (!part || !part.bytes.length) break
832 const lp = join(os.tmpdir(), 'favela-level-' + Date.now() + '-' + n + '-' + Math.random().toString(16).slice(2))
833 fs.writeFileSync(lp, part.bytes)
834 tmps.push(lp)
835 levels.push({ path: lp, w: Number(form.fields['level-' + n + '-w']) || 0, h: Number(form.fields['level-' + n + '-h']) || 0 })
836 }
837
820838 let stored
821839 try {
822 stored = await client.call('file-put', { path: tmp })
840 stored = levels.length > 1
841 ? await client.call('file-put-levels', { levels })
842 : await client.call('file-put', { path: tmp })
823843 } catch (err) {
824844 return seeOther(res, '/?m=' + encodeURIComponent(err.message))
825845 } finally {
826 try { fs.unlinkSync(tmp) } catch {}
846 for (const t of tmps) { try { fs.unlinkSync(t) } catch {} }
827847 }
828848 // The alt text is the filename the browser sent — the only label we have, and it is escaped
829849 // wherever it is rendered. ⚠ It is a CLAIM by the uploader; the TYPE came from the bytes.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.48",
3 "version": "0.0.49",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
static/composer.js +53 -0
……@@ -59,3 +59,56 @@ if (ta && box) {
5959 })
6060 ta.addEventListener('blur', () => setTimeout(hide, 120))
6161}
62
63// ── the mip pyramid ───────────────────────────────────────────────────────────────────────────
64//
65// ⛔ PROGRESSIVE, like the picker above it. With this block missing the form posts the original file
66// as a single part and the daemon stores one level — which is exactly today's behaviour and stays
67// correct. With it present, the browser also renders the smaller levels so a bridge can SELECT one
68// that fits its destination instead of the picture being withheld for being 40 KB over a ceiling.
69//
70// ⛔ THE BASE LEVEL IS THE AUTHOR'S OWN BYTES, uploaded untouched. Only the derived levels are
71// re-encoded, and they are marked as authored because the author's own machine made them at compose
72// time — a level a BRIDGE generates later is `derived:` and that distinction is §6.6.4's, not ours.
73import { planLevels, renderLevel } from './levels.js'
74
75const form = document.querySelector('form.composer')
76const input = form && form.querySelector('input[type=file][name=file]')
77
78if (form && input && window.createImageBitmap && window.FormData) {
79 form.addEventListener('submit', async (ev) => {
80 const file = input.files && input.files[0]
81 if (!file || !/^image\//.test(file.type)) return // let the plain form post handle it
82
83 ev.preventDefault()
84 const fd = new FormData(form)
85
86 try {
87 const bitmap = await createImageBitmap(file)
88 const plan = planLevels(bitmap.width, bitmap.height)
89 // The base's real dimensions travel too — bsky lays an image out from aspectRatio, and without
90 // them every favela picture has been laid out from a guess.
91 fd.set('file-w', String(bitmap.width))
92 fd.set('file-h', String(bitmap.height))
93
94 let n = 0
95 for (const level of plan) {
96 const out = await renderLevel(bitmap, level.edge, level.quality)
97 // ⚠ A level BIGGER than the source is not a level. Re-encoding can inflate a small PNG, and
98 // shipping that would cost bytes and buy nothing.
99 if (!out || out.blob.size >= file.size) continue
100 fd.append('level-' + n, out.blob, level.name + '.jpg')
101 fd.set('level-' + n + '-w', String(out.width))
102 fd.set('level-' + n + '-h', String(out.height))
103 n++
104 }
105 if (typeof bitmap.close === 'function') bitmap.close()
106 } catch {
107 // ⛔ A failure here must not lose the post. Fall through and send what the form already holds:
108 // the original file, one level, which is what a browser without this code sends anyway.
109 }
110
111 const res = await fetch(form.action || '/post', { method: 'POST', body: fd, redirect: 'follow' })
112 window.location = res.redirected ? res.url : '/'
113 })
114}
static/levels.js +52 -0
……@@ -0,0 +1,52 @@
1// The mip ladder, browser side. Pure — the canvas work that acts on this lives in composer.js.
2//
3// ⛔ THIS IS WHERE THE PYRAMID IS BUILT. SOCIAL-MODEL §6.6.1: downsampling happens ONCE, at
4// authoring, deterministically — like generating mips at asset build time — so a bridge SELECTS a
5// level instead of re-encoding per delivery. §6.6.5 measured that the encoder for images is
6// `<canvas>`, already in the runtime, costing no binary size; favela-android is a WebView shell, so
7// the handset gets this same path.
8//
9// ⚠ Mirrors favela-peer/lib/levels.js. Two copies of a ladder is the shape this monorepo has been
10// bitten by before, and the alternative here is worse: the peer runs under Bare and this runs in a
11// browser with no module server between them. The ladder is data, the test suites assert the same
12// numbers, and the peer is authoritative if they ever disagree.
13
14export const IMAGE_LEVELS = [
15 { name: 'full', edge: 2000, quality: 0.92 },
16 { name: 'large', edge: 1280, quality: 0.86 },
17 { name: 'small', edge: 640, quality: 0.82 },
18 { name: 'thumb', edge: 320, quality: 0.78 }
19]
20
21/** ⛔ NEVER UPSCALE — a level is a floor. An image smaller than the lowest level has one level. */
22export function planLevels (width, height) {
23 const longest = Math.max(Number(width) || 0, Number(height) || 0)
24 if (!longest) return []
25 return IMAGE_LEVELS.filter((l) => l.edge < longest)
26}
27
28export function scaleTo (width, height, edge) {
29 const longest = Math.max(width, height)
30 if (!longest || edge >= longest) return { width, height }
31 const s = edge / longest
32 return { width: Math.max(1, Math.round(width * s)), height: Math.max(1, Math.round(height * s)) }
33}
34
35/**
36 * Render one level. Resolves `{ blob, width, height }` or null.
37 *
38 * ⚠ JPEG for every derived level regardless of the source type: they exist to fit a ceiling, and
39 * PNG re-encoding a photograph produces a level LARGER than the original, which defeats the point.
40 * The base level is never touched — it is the author's own bytes and is uploaded as they are.
41 */
42export async function renderLevel (bitmap, edge, quality) {
43 const { width, height } = scaleTo(bitmap.width, bitmap.height, edge)
44 const canvas = document.createElement('canvas')
45 canvas.width = width
46 canvas.height = height
47 const ctx = canvas.getContext('2d')
48 if (!ctx) return null
49 ctx.drawImage(bitmap, 0, 0, width, height)
50 const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality))
51 return blob ? { blob, width, height } : null
52}