Files
houseplan-card/demo/benchmark_backdrop_decode.mjs
T
Codex 7c31725ac9 feat: warn about huge backdrops and offer a safe reduced copy (#39)
A picked raster is now classified from its HEADER BYTES ONLY before anything
heavy happens: src/backdrop-probe.ts parses PNG IHDR (+colour type/tRNS for
alpha), JPEG SOF and WebP VP8/VP8L/VP8X at fixed offsets, never using a file
field as an allocation size; hostile or truncated headers collapse to
'unknown', which warns without numbers instead of passing silently. The
thresholds live in that module as the single calibration point
(WARN_DECODED_BYTES 128 MiB ≈ 32 MP, HARD_DIMENSION 16384 — the browser
canvas cap, DOWNSCALE_TARGET_PX 4096), derived from the desktop-Chromium
matrix now committed as demo/benchmark_backdrop_decode.mjs with a
conservative tablet margin documented in the spec.

The shared pick flow (src/backdrop-pick.ts) feeds BOTH lazy runtimes — the
editor space dialog and the onboarding first-space dialog — so the guard
cannot drift between them, and nothing of it enters the eager View graph.
Warn shows the real numbers and three actions; the reduced copy decodes
EXIF-aware, keeps aspect and alpha (PNG stays PNG, opaque becomes JPEG
q0.9) and flows through the ordinary planFile → upload path. Hard has two
phases with one outcome: beyond 16384 px only Cancel; a failed or timed-out
(10 s) reduce closes with a toast, clean staging and NO silent fallback to
the original the user just declined. SVG never reaches the probe. The safe
path swaps the manual byte-loop base64 for FileReader — half the JS-heap
peak on every upload, byte-identical output (parity asserted in the smoke).

Proofs: header-table units incl. a fuzz set of hostile headers and ±1
threshold bounds; smoke_backdrop_guard on the real bundle — zero decode
calls before the choice, byte parity of keep-original, a real 6200 px
reduce to 4096 for both alpha and opaque branches, cancel-only hard
dialog, both phase-2 failures (reject and hang under the test-only timeout
override), re-pick after refusal, SVG bypass; four registry mutants
(probe-always-safe, alpha-dropped, hard-demoted, phase-2 silent fallback).
Spec anchor corrected alongside: the server plan limit is 8 MB
(MAX_PLAN_BYTES), attachments are the 50 MB path — an 8 MB JPEG is easily
80-160 MP decoded, so the client-side guard stays the primary defence.

Issue: #39
User-Visible: yes
2026-08-29 10:13:09 +03:00

57 lines
2.8 KiB
JavaScript

// #39: decode/downscale calibration matrix behind the backdrop guard
// thresholds (docs/specs/039-large-backdrops.md §Research). Not a CI gate —
// rerun by hand when recalibrating WARN_DECODED_BYTES / HARD_DIMENSION.
// PLAYWRIGHT_BROWSERS_PATH=... node demo/benchmark_backdrop_decode.mjs
import { chromium } from 'playwright';
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext()).newPage();
const session = await page.context().newCDPSession(page);
await page.goto('about:blank');
const metrics = async () => {
const m = await session.send('Performance.getMetrics');
const get = (n) => m.metrics.find((x) => x.name === n)?.value || 0;
return { jsHeap: Math.round(get('JSHeapUsedSize') / 1048576) };
};
const cases = [
{ mp: 4, alpha: false }, { mp: 8, alpha: false }, { mp: 16, alpha: false },
{ mp: 32, alpha: false }, { mp: 32, alpha: true }, { mp: 64, alpha: false },
{ mp: 100, alpha: false }, { mp: 165, alpha: false },
];
for (const c of cases) {
try {
const r = await page.evaluate(async ({ mp, alpha }) => {
const side = Math.round(Math.sqrt(mp * 1e6));
const w = side, h = side;
// сгенерировать PNG нужного размера
const cv = new OffscreenCanvas(w, h);
const ctx = cv.getContext('2d');
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, alpha ? 'rgba(200,30,30,0.5)' : '#c81e1e');
g.addColorStop(1, '#1e50c8');
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 200; i++) { ctx.fillStyle = `hsl(${i*7},60%,50%)`; ctx.fillRect((i*97)%w, (i*61)%h, 40, 40); }
const blob = await cv.convertToBlob({ type: 'image/png' });
// decode: createImageBitmap
const t0 = performance.now();
const bmp = await createImageBitmap(blob);
const decodeMs = Math.round(performance.now() - t0);
// downscale до 4096 по длинной стороне
const target = 4096;
const scale = Math.min(1, target / Math.max(bmp.width, bmp.height));
const t1 = performance.now();
const out = new OffscreenCanvas(Math.round(bmp.width*scale), Math.round(bmp.height*scale));
out.getContext('2d').drawImage(bmp, 0, 0, out.width, out.height);
const outBlob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
const downMs = Math.round(performance.now() - t1);
bmp.close();
return { w, h, fileMB: +(blob.size/1048576).toFixed(1), rgbaMB: Math.round(w*h*4/1048576),
decodeMs, downMs, outMB: +(outBlob.size/1048576).toFixed(1) };
}, c);
const m = await metrics();
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, ...r, jsHeapMB: m.jsHeap }));
} catch (e) {
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, FAIL: String(e).slice(0, 120) }));
}
}
await browser.close();