| … | … | @@ -817,13 +817,33 @@ export async function route (req, res, ctx) { |
| 817 | 817 | // copies what it keeps, so this temp file is ours to remove. |
| 818 | 818 | const tmp = join(os.tmpdir(), 'favela-post-' + Date.now() + '-' + Math.random().toString(16).slice(2)) |
| 819 | 819 | 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 | ||
| 820 | 838 | let stored |
| 821 | 839 | 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 }) | |
| 823 | 843 | } catch (err) { |
| 824 | 844 | return seeOther(res, '/?m=' + encodeURIComponent(err.message)) |
| 825 | 845 | } finally { |
| 826 | try { fs.unlinkSync(tmp) } catch {} | |
| 846 | for (const t of tmps) { try { fs.unlinkSync(t) } catch {} } | |
| 827 | 847 | } |
| 828 | 848 | // The alt text is the filename the browser sent — the only label we have, and it is escaped |
| 829 | 849 | // wherever it is rendered. ⚠ It is a CLAIM by the uploader; the TYPE came from the bytes. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface", |
| 3 | "version": "0.0.48", | |
| 3 | "version": "0.0.49", | |
| 4 | 4 | "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -59,3 +59,56 @@ if (ta && box) { |
| 59 | 59 | }) |
| 60 | 60 | ta.addEventListener('blur', () => setTimeout(hide, 120)) |
| 61 | 61 | } |
| 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. | |
| 73 | import { planLevels, renderLevel } from './levels.js' | |
| 74 | ||
| 75 | const form = document.querySelector('form.composer') | |
| 76 | const input = form && form.querySelector('input[type=file][name=file]') | |
| 77 | ||
| 78 | if (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 | } |
| … | … | @@ -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 | ||
| 14 | export 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. */ | |
| 22 | export 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 | ||
| 28 | export 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 | */ | |
| 42 | export 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 | } |