mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
57 lines
2.8 KiB
JavaScript
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();
|